⭐ 1.6 空值¶
在上一节,使用Console.ReadLine()的时候引发了两个警告:
CS8600:将 null 文本或可能的 null 值转换为不可为 null 类型。
CS8602:解引用可能出现空引用。
当时说暂时忽略它们,要是有强迫症的话,你一定早就看它们不顺眼了。现在我们就来动手解决吧。
可空类型¶
将鼠标指针停留在ReadLine()方法上,检查一下IntelliSense提示:

原来这个方法的返回值的类型是string?。为什么有个问号?难道它也不确定返回的是不是字符串?
在某个类型名称后面加一个问号,会让这个类型变成可为null类型,或者说可空类型(nullable types)。null是什么意思?就是“没有”、“空”的意思!
null是空字符串吗?
if (null == string.Empty)
{
Console.WriteLine("This will never be printed.");
}
else
{
Console.WriteLine("This will always be printed.");
}
很明显不是。如果string实例是杯子的话,string.Empty就是空杯子,而null表示根本没有杯子。同理,null是0吗?也不是。0就是数字0,null是没有数字的意思。
我们这就来声明一些可空变量玩玩:
string? nullableString = null;
nullableString = "I'm not null anymore!";
nullableString = null;
int? nullableInt = null;
nullableInt = 100;
nullableInt = null;
真不错!普通类型变成可空类型之后,它们的值就能是null了。那这有什么用呢?
有一大用处是用null来作为初始值或者默认值。我们上一节不是搞了一个猜数字游戏嘛?当时我们设计了一个布尔变量isGuessed来表示玩家有没有赢,并且给它定的默认值是false(输)对不对?
假如把这个游戏做成web应用,就会有用户明明猜中了数字,但因为网络原因没能把isGuessed设置成true的可能性。假如isGuessed在赢(true)和输(false)之外有第三个选择null,用来表示“胜负未定”的状态不就能改善这个问题吗?
第二个用处是,我们本来期望某项操作产出一个东西,但是有失败的风险。因此我们可以把这个操作的返回值设置为可空类型,万一操作失败了就返回null作为保底,避免直接抛出错误。而我们也正好可以通过检查产物是不是null来知晓操作究竟有没成功。发现了吗?Console.ReadLine()不正是这样吗!
可空类型与原类型之间的转换¶
对于原类型来说,可空类型虽然是“亲戚”,但毕竟不是一家人,不进一家门:
❌CS0266:无法将类型“double?”隐式转换为“double”。存在一个显式转换(是否缺少强制转换?)
是的,又回到隐式转换了。复习一下:范围小的能隐式转换为范围大的,对吧?可空类型比原类型的表达范围多了一个null,因此可空类型不能隐式转换为原类型。而反过来就可以:
为什么string?转换为string不是引发错误,而是警告?噢~它终于露出马脚了。本章介绍的所有类型都是“值类型”,只有string类型是例外——它是披着值类型皮的“引用类型”。我们先不深究什么是“值类型”,什么是“引用类型”。
但是提示一下,“数值类型”(numeric types)和“值类型”(value types)是两个概念,前者是数字,包括整数类型和浮点数类型。
行。那怎么安全地把可空类型转换为原类型呢?对于值类型,我们有三板斧:HasValue、Value、GetValueOrDefault()。前两个不带括号(),是实例属性;最后一个带括号,是实例方法。
让我们以字符为值类型的代表。先创建一个可空字符实例:
由于我们不知道它是空的还是存有字符,所以先用HasValue来做个判断:
char? nullable = null;
if (nullable.HasValue)
{
Console.WriteLine("它不是空的");
}
else
{
Console.WriteLine("它是空的");
}
如果它不是空的,那我们就可以用Value来获取它的值,把值安全地赋予char类型的变量result。万一是空的呢?我们可以提前给result设定一个初始值,假如可空变量是空的就继续用初始值好了:
char? nullable = null;
char result = '\0'; // 设置初始值
if (nullable.HasValue)
{
Console.WriteLine("它不是空的");
result = nullable.Value;
}
else
{
Console.WriteLine("它是空的");
}
上面方法虽然麻烦了一点,但好处是我们可以自由设置变量的初始值。如果想一步到位,可以用GetValueOrDefault()方法。顾名思义,如果可空变量不是空的就获取它的值,反之返回原类型的默认值。
下面的列表给出了目前我们学过的各种类型的默认值,它们都是固定的:
| 类型 | 默认值 |
|---|---|
| 数值类型 | 0 |
| bool类型 | false |
| char类型 | '\0' |
引用类型(包括string) |
null |
Note
注意string类型的默认值是null,而不是string.Empty。
重申:虽然上表把引用类型也列进去了,但刚刚说的三板斧是针对值类型的。你可以发现string?类型根本不支持HasValue、Value或GetValueOrDefault()。
对于可空字符串类型,我们使用静态方法string.IsNullOrEmpty()来判断它是否为null或string.Empty。类似的方法还有判断是否为null或者空格的string.IsNullOrWhiteSpace()。
别绕晕了!注意它们是静态方法,和string类型绑定。但它们判断的对象是string?类型的实例:
与可空性有关的运算符¶
空包容运算符¶
可空性是一个让人又爱又恨的功能。一方面,它给我们提供了一个绝佳的表示值缺失、状态异常等意图的工具;另一方面,它也让各种警告和错误常驻你的错误列表。
有时候,让人抓狂的是“明明这个实例非空,但编译器硬说它可能是空值”。遇到这种情况,在经过仔细确认实例的确不可能是null的时候,可以在实例名称的末尾加一个感叹号!,告诉编译器这个实例真的不是null。
string? maybeNull = Console.ReadLine();
string absoluteNotNull = maybeNull!;
int len = maybeNull!.Length;
本来可空的maybeNull赋值给absoluteNotNull必然会引发警告“将 null 文本或可能的 null 值转换为不可为 null 类型”。但是加上感叹号,也就是空包容运算符以后,编译器也就不再追究这个问题,而是选择相信你的判断。
当然,要是被你断言不可能为空的实例真的为空了,就准备好接受NullReferenceException等运行时错误的拷打吧!
Note
请注意区分空包容运算符(位于可空实例末尾)和逻辑取反运算符(位于布尔类型实例的前面)。
空合并运算符¶
检查可空实例是否为空,如果非空就取它的值,如果是空的就用备用值。这是一个可空变量的高频使用场景。我们刚才一直在用if-else语句,不如试一下三元表达式吧:
// 值类型
char? nullable = null;
char result1 = nullable.HasValue ? nullable.Value : '\0';
// 引用类型
string? input = Console.ReadLine();
string result2 = (!string.IsNullOrEmpty(input)) ? input : "";
值类型和引用类型都可以通过与null做相等/不相等关系运算判断是否为空:
// 值类型
char? nullable = null;
char result1 = (nullable != null) ? nullable.Value : '\0';
// 引用类型
string? input = Console.ReadLine();
string result2 = (input != null) ? input : "";
而且,三元表达式还能继续简化为空合并表达式,真可谓语法糖的语法糖:
// 值类型
char? nullable = null;
char result1 = nullable ?? '\0';
// 引用类型
string? input = Console.ReadLine();
string result2 = input ?? "";
空合并运算符??是一个二元运算符。A ?? B表示,如果A不是空的,就返回A;否则返回B。通常A是可能为空的值,B是其备用值。
记忆助手
让谁上场 = 主力选手 ?? 替补选手
空合并运算符也支持复合赋值。回忆一下,a += 2就是a = a + 2的意思。类推过来,A ??= B就应该是A = A ?? B。也就是说,先判断A是不是空的,如果不是,就把A赋值给A——原地TP,什么也没做;否则把B赋值给A。
测验时间¶
每次讲到语法糖,都是先展示复杂的语法,再介绍简化后的语法。这次我们反过来,请把A ??= B还原为if-else语句。
条件访问和条件索引¶
费了一番功夫,我们总算是把警告 CS8600:“将 null 文本或可能的 null 值转换为不可为 null 类型”给搞明白了。也对可空值类型和引用类型分别给出了安全的转换为原类型的方法。
那我们现在就转向第二个警告 CS8602:解引用可能出现空引用。
“引用”?看来这个警告和引用类型有关。暂且建立一个对值类型和引用类型的初步认知吧。
我想喝奶茶🧋。也许我桌面上正好放着一杯,直接拿过来就喝。这就是我们一直以来对待数值、布尔、字符等值类型的方式。
但是引用类型就有点不同了:它不是一杯随手拿得到的奶茶,而是——商铺的点单程序。下单之后,外卖骑手会把奶茶从店铺送到我手上,这个过程就叫做解引用(dereferencing)。
很好理解,假如这家店已经摇摇欲坠,有倒闭的风险(可为空),点单之后,骑手过去发现已经人去楼空,给我配送空气吗?
连奶茶都没送来,还想对着空气研究是不是用的红茶么?有点搞笑了。

测验时间¶
用上刚刚学会的知识,判断milktea是否为空,然后仅对非空的情况搜索是否包含"black tea"。
查看答案
条件访问运算符?.就是专门用于这种情况的语法糖。
它的含义很明确:想要通过A?.B访问可空实例A的成员B,先检查A是不是null。如果不是,那就正常访问B,并返回B的返回值;如果A是空的,那就返回null。
“返回B的返回值”好像有点“粉碎粉碎机的粉碎机”的意思。但是看下面两个例子你就明白了:
string? milktea = "Made with milk and black tea.";
// (1)访问方法
bool? a = milktea?.Contains("black tea");
// (2)访问属性
int? b = milktea?.Length;
条件访问运算符检测到milktea非空,访问方法Contains(),它会返回true。这个true再被条件访问运算符返回给变量a。访问属性Length的话,它也会返回一个整数29,条件访问运算符再把这个29返回给变量b。
如果你不希望a和b是可空类型的话,就使用可空运算符给它们一个兜底的备用值吧:
string? milktea = "Made with milk and black tea.";
// (1)访问方法
bool a = milktea?.Contains("black tea") ?? false;
// (2)访问属性
int b = milktea?.Length ?? 0;
这样一来,如果milktea是null,成员无法访问,我们就能立刻启用备用值。
条件索引运算符?[]的原理和条件访问完全一样。A?[i]尝试获取A的第i个元素,假如A非空就正常地获取;反之返回null。
假如milktea不是null就获取它的首个字符,以及最后3个字符:
string? milktea = "Made with milk and black tea.";
char? a = milktea?[0];
string? b = milktea?[^3..];
第一章到这里就结束了!为你的毅力喝彩🎊🎊🎊