Skip to content

⭐ 1.6 空值

在上一节,使用Console.ReadLine()的时候引发了两个警告:

  • ⚠ CS8600:将 null 文本或可能的 null 值转换为不可为 null 类型。
  • ⚠ CS8602:解引用可能出现空引用。

当时说暂时忽略它们,要是有强迫症的话,你一定早就看它们不顺眼了。现在我们就来动手解决吧。

可空类型

将鼠标指针停留在ReadLine()方法上,检查一下IntelliSense提示:

fig

原来这个方法的返回值的类型是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,用来表示“胜负未定”的状态不就能改善这个问题吗?

bool? isGuessed = null;

第二个用处是,我们本来期望某项操作产出一个东西,但是有失败的风险。因此我们可以把这个操作的返回值设置为可空类型,万一操作失败了就返回null作为保底,避免直接抛出错误。而我们也正好可以通过检查产物是不是null来知晓操作究竟有没成功。发现了吗?Console.ReadLine()不正是这样吗!

可空类型与原类型之间的转换

对于原类型来说,可空类型虽然是“亲戚”,但毕竟不是一家人,不进一家门:

double? nullableDouble = 2.71;
double normalDouble = nullableDouble;

❌CS0266:无法将类型“double?”隐式转换为“double”。存在一个显式转换(是否缺少强制转换?)

是的,又回到隐式转换了。复习一下:范围小的能隐式转换为范围大的,对吧?可空类型比原类型的表达范围多了一个null,因此可空类型不能隐式转换为原类型。而反过来就可以:

double normalDouble = 2.71;
double? nullableDouble = normalDouble;

为什么string?转换为string不是引发错误,而是警告?噢~它终于露出马脚了。本章介绍的所有类型都是“值类型”,只有string类型是例外——它是披着值类型皮的“引用类型”。我们先不深究什么是“值类型”,什么是“引用类型”。

但是提示一下,“数值类型”(numeric types)和“值类型”(value types)是两个概念,前者是数字,包括整数类型浮点数类型

行。那怎么安全地把可空类型转换为原类型呢?对于值类型,我们有三板斧:HasValueValueGetValueOrDefault()。前两个不带括号(),是实例属性;最后一个带括号,是实例方法。

让我们以字符为值类型的代表。先创建一个可空字符实例:

char? nullable = null;

由于我们不知道它是空的还是存有字符,所以先用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?类型根本不支持HasValueValueGetValueOrDefault()

对于可空字符串类型,我们使用静态方法string.IsNullOrEmpty()来判断它是否为nullstring.Empty。类似的方法还有判断是否为null或者空格的string.IsNullOrWhiteSpace()

别绕晕了!注意它们是静态方法,和string类型绑定。但它们判断的对象是string?类型的实例:

string? a = null;

if (string.IsNullOrEmpty(a))
{
    Console.WriteLine("a is null or empty.");
}

Tip

在字符串连接内插操作中,C# 编译器将null字符串视为空字符串""进行处理。

与可空性有关的运算符

空包容运算符

可空性是一个让人又爱又恨的功能。一方面,它给我们提供了一个绝佳的表示值缺失、状态异常等意图的工具;另一方面,它也让各种警告和错误常驻你的错误列表。

有时候,让人抓狂的是“明明这个实例非空,但编译器硬说它可能是空值”。遇到这种情况,在经过仔细确认实例的确不可能是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语句。

查看答案
if (A == null)
{
    A = B;
}

条件访问和条件索引

费了一番功夫,我们总算是把警告 ⚠ CS8600:“将 null 文本或可能的 null 值转换为不可为 null 类型”给搞明白了。也对可空值类型和引用类型分别给出了安全的转换为原类型的方法。

那我们现在就转向第二个警告 ⚠ CS8602:解引用可能出现空引用。

“引用”?看来这个警告和引用类型有关。暂且建立一个对值类型和引用类型的初步认知吧。

我想喝奶茶🧋。也许我桌面上正好放着一杯,直接拿过来就喝。这就是我们一直以来对待数值、布尔、字符等值类型的方式。

但是引用类型就有点不同了:它不是一杯随手拿得到的奶茶,而是——商铺的点单程序。下单之后,外卖骑手会把奶茶从店铺送到我手上,这个过程就叫做解引用(dereferencing)。

很好理解,假如这家店已经摇摇欲坠,有倒闭的风险(可为空),点单之后,骑手过去发现已经人去楼空,给我配送空气吗?

string? milktea = null;
bool a = milktea.Contains("black tea");

连奶茶都没送来,还想对着空气研究是不是用的红茶么?有点搞笑了。

fig

测验时间

用上刚刚学会的知识,判断milktea是否为空,然后仅对非空的情况搜索是否包含"black tea"

查看答案
string? milktea = null;
bool? a = null;

if (milktea != null)
{
    a = milktea.Contains("black tea");
}

条件访问运算符?.就是专门用于这种情况的语法糖。

string? milktea = null;
bool? a = milktea?.Contains("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

如果你不希望ab是可空类型的话,就使用可空运算符给它们一个兜底的备用值吧:

string? milktea = "Made with milk and black tea.";

// (1)访问方法
bool a = milktea?.Contains("black tea") ?? false;
// (2)访问属性
int b = milktea?.Length ?? 0;

这样一来,如果milkteanull,成员无法访问,我们就能立刻启用备用值。

条件索引运算符?[]的原理和条件访问完全一样。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..];

第一章到这里就结束了!为你的毅力喝彩🎊🎊🎊