第 15 节 · 第二部分 流程、方法与数据

可空类型

值类型本来必须有值;类型后加 ? 就多了一个“没有值”的状态(HasValue 旗 + Value 格),取值时用 ?? / GetValueOrDefault 给备用值最安全。

时长 7:49授课计划第 5 周

学完这一节,你能

讲义

跟着视频的顺序写。每行代码都有注释,代码和运行结果都是实际运行得到的。

1值类型不能为空

有时候结果根本就没有,比如查一个人的年龄,数据库里偏偏没填。可 int、float、bool、DateTime 这些值类型必须有一个值,不能空着;硬给它赋值为空值 null,编译直接报错(本节例子都是顶级语句写法:不写类和 Main,代码从第一行开始执行):

C# · 第 1 行
1int a = null;// 给普通 int 赋值为空值
编译报错
Program.cs(1,9): error CS0037: 无法将 null 转换为“int”,因为后者是不可为 null 的值类型

2可空类型 int?

在类型后面加一个问号,int? 就是一个可以为空的 int。数据库字段、API(别的程序提供给你调用的接口)返回值这些可能缺数据的地方特别常用。int? 是简写,完整写法是 Nullable<int>:

C#
1Nullable<int> a = null; // 完整写法// a 是空值
2int? b = null; // 简写,效果相同// b 也是空值
3Console.WriteLine(typeof(int?) == typeof(Nullable<int>));// typeof 取出类型来比较:两种写法是同一个类型
4Console.WriteLine(a == b);// 两个空值相等
运行结果
True
True

3HasValue 和 Value

可空类型里有两样东西:Value 是值,HasValue 表示有没有值。

C# · 第 3-8 行
3int? x = 42;// x 有值 42
4int? y = null;// y 是空值
5Console.WriteLine($"x.HasValue = {x.HasValue}, x.Value = {x.Value}");// 有值:HasValue 是 True,Value 是 42
6Console.WriteLine($"y.HasValue = {y.HasValue}");// 没有值:HasValue 是 False
7Console.WriteLine($"int 占 {Unsafe.SizeOf<int>()} 字节");// Unsafe.SizeOf 量出一个类型占几个字节:int 占 4 字节
8Console.WriteLine($"int? 占 {Unsafe.SizeOf<int?>()} 字节");// int? 占 8 字节

int? 比 int 多占地方:实测 int 占 4 字节,int? 占 8 字节,多出来的地方用来记“有没有值”(HasValue),外加内存对齐(让数据按 4 字节排整齐)补的空位。真实内存里 HasValue 就是一个布尔字段,记着真还是假。

4? 和 ??

两个长得很像的符号:一个问号 ? 是可空类型修饰符,让值类型可以为空,int? i = 3 等价于新建一个装着 3 的 Nullable<int>;两个问号 ?? 叫空合并运算符,变量为空时给一个默认值,比如 i ?? 0:i 为空,结果就是 0。

5默认值:局部变量要先赋值

int i; 和 int? ii; 只声明不赋值,直接打印,结果是编译错误:

C#
1int i; // 普通值类型,默认值为 0,永远不能是 null// 只声明,没赋值
2int? ii; // 可空类型,默认值为 null// 只声明,没赋值
3Console.WriteLine(i);// 使用没赋值的局部变量
4Console.WriteLine(ii);// 同样没赋值
编译报错
Program.cs(3,19): error CS0165: 使用了未赋值的局部变量“i”
Program.cs(4,19): error CS0165: 使用了未赋值的局部变量“ii”

方法里声明的局部变量,不赋值就不能用。默认的 0 和空值只用在字段、数组元素和 default 这些地方:

C#
1Console.WriteLine($"default(int) = {default(int)}");// default(类型) 取这个类型的默认值:int 是 0
2Console.WriteLine($"default(int?) == null:{default(int?) == null}");// int? 的默认值是空值
3int i = 0;// 局部变量先赋值 0
4int? ii = null;// 局部变量先赋值为空值
5Console.WriteLine($"i = {i}, ii.HasValue = {ii.HasValue}");// 赋过值就能用了:i = 0, ii.HasValue = False

6声明可空变量

格式是:数据类型加问号,再写变量名。运行后只有体温 36.6 有值,另外三个的 HasValue 都是 False。

C# · 第 1-4 行
1int? age = null;// 可空 int:年龄没填
2double? temperature = 36.6;// 可空 double:体温 36.6
3bool? isActive = new bool?(); // 显式构造,默认为 null// new bool?() 得到的也是空值
4DateTime? birthday = null;// 可空 DateTime:生日没填

int? 能装下 int 的全部范围(实测从 -2147483648 到 2147483647),再加上一个空值。

7完整示例:打印可空变量

五个可空变量:num1、num3、boolVal 为空,num2 是 45,num4 是 3.14157;打印时前四个用 ?? 给了备用值。

C# · 第 17-21 行
17Console.WriteLine($"num1 = {num1 ?? 0}"); // 0// num1 为空,打印备用值 0
18Console.WriteLine($"num2 = {num2 ?? 0}"); // 45// num2 是 45,打印原值
19Console.WriteLine($"num3 = {num3 ?? 0.0}"); // 0// num3 为空,备用值 0.0 打印成 0
20Console.WriteLine($"num4 = {num4 ?? 0.0}"); // 3.14157// num4 是 3.14157,打印原值
21Console.WriteLine($"boolVal = {boolVal}"); // (空)// boolVal 为空又没给备用值,等号后面什么也没有

8空合并运算符 ??

规则:左边不为空就取左边,为空就取右边。?? 还能连着写,依次试,谁先有值就取谁。

C# · 第 13-14 行
13double result1 = num1 ?? 5.34; // num1 为 null → 返回 5.34// num1 是空值,取右边 5.34
14double result2 = num2 ?? 5.34; // num2 有值 → 返回 3.14157// num2 有值,取它自己
C# · 第 23 行
23int value = a ?? b ?? c ?? 0; // 依次尝试,最终得到 42// a、b 都为空,c 是 42,取 42

从 C# 8.0 起还有 ??=:只在变量为空时才赋值。

C#
1int? x = null;// x 是空值
2x ??= 10; // x 为 null,赋值为 10// 第一次:x 为空,赋值为 10
3Console.WriteLine(x);// 打印 10
4x ??= 20; // x 已有值 10,不再赋值// 第二次:x 已经有值,不改
5Console.WriteLine(x);// 还是 10

9常用成员

  • HasValue:有没有值,返回 True 或 False。
  • Value:取出真正的值,为空时会抛出异常。
  • GetValueOrDefault():安全取值,为空时返回类型的默认值(比如 0);括号里写一个数,为空时就返回这个数。

示例三里 num 为空,先用 HasValue 判断,打印“num 没有值”,再安全取值:

C# · 第 18-20 行
18Console.WriteLine(num.GetValueOrDefault()); // 0// num 为空,返回 int 的默认值 0
19Console.WriteLine(num.GetValueOrDefault(99)); // 99// 为空,返回括号里的 99
20Console.WriteLine(num ?? 99); // 99(等价写法)// ?? 也得到 99

直接取空值的 Value,程序当场崩溃,抛出 InvalidOperationException(可空对象必须有值):

C# · 第 1-2 行
1int? num = null;// num 是空值
2int x = num.Value;// 对空值取 Value:运行时抛异常

运行结果第一行是 Unhandled exception. System.InvalidOperationException: Nullable object must have a value.。编辑器早有提醒,这一行有绿色波浪线:warning CS8629: 可为 null 的值类型可为 null。

?? 和 GetValueOrDefault 结果一样,但不完全等价:GetValueOrDefault 的备用值不管用不用得上都会先算一遍,?? 只在左边为空时才去算右边:

C# · 第 2-10 行
2int Backup()// Backup 方法:算备用值
3{// Backup 开始
4 Console.WriteLine(" (算了一次备用值)");// 每被调用一次就打印一行
5 return 100;// 交回备用值 100
6}// Backup 结束
7Console.WriteLine("GetValueOrDefault(Backup()):");// 打印标题
8Console.WriteLine(num.GetValueOrDefault(Backup()));// num 是 5,用不上备用值,但 Backup() 还是先执行了
9Console.WriteLine("num ?? Backup():");// 打印标题
10Console.WriteLine(num ?? Backup());// num 不为空,Backup() 根本不执行
运行结果
GetValueOrDefault(Backup()):
  (算了一次备用值)
5
num ?? Backup():
5

10实际场景

数据库字段

2 号用户的年龄是空值,用 int? 接住,再用 HasValue 判断:

C# · 第 1-9 行
1int? age = GetUserAgeFromDB(userId: 2);// 2 号用户的年龄没填,拿到空值
2
3// 安全处理 null
4string display = age.HasValue// 有值吗?
5 ? $"用户年龄:{age.Value}"// 有值:显示年龄
6 : "年龄未知";// 没值:显示“年龄未知”
7
8// 或者更简洁的写法
9string display2 = $"用户年龄:{age ?? 0}";// 为空时用 0 代替
运行结果
年龄未知
用户年龄:0

?? 0 的写法运行出来是“用户年龄:0”,像是刚出生。备用值要选有意义的,没填的年龄还是显示“年龄未知”更合适。

配置项

C# · 第 2-3 行
2int? timeout = GetConfigValue("timeout");// 配置里没写 timeout,拿到空值
3int actualTimeout = timeout ?? 30; // 未设置时使用默认值 30 秒// 为空,取默认的 30

链式检查 ?.

User、Address、City 是程序末尾定义的三个小类,一个套一个:

C# · 第 11-13 行
11class User { public Address? Address; }// 用户里有一个可空的地址
12class Address { public City? City; }// 地址里有一个可空的城市
13class City { public string? Name; }// 城市里有一个可空的名字

一路往下取:用户、地址、城市、名字。只要哪一环为空,结果就是空值,不会抛异常:

C# · 第 1-5 行
1User? user = null;// 用户是空值
2// 使用 ?. 运算符安全访问可能为 null 的对象
3string? name = user?.Address?.City?.Name;// user 为空,后面不再往下取,name 是空值
4// 如果 user、Address、City 中任何一个为 null,结果就是 null,不会抛异常
5Console.WriteLine(name ?? "(null)");// 为空,打印“(null)”

给每一环都填上值以后再取一遍,就取到了“青岛”。

11小结

  • 定义可空类型,就在类型后面加问号。
  • HasValue 判断有没有值,Value 直接取值但不安全。
  • ?? 和 GetValueOrDefault 给备用值;??= 只在为空时赋值;?. 安全地一路往下取。

12可空引用类型

从 C# 8.0 开始还有一套可空引用类型,和今天讲的可空值类型是两套机制:可空值类型管 int、bool 这些值类型,运行时真有一个 Nullable 结构(struct,一种值类型,后面会讲)来装;可空引用类型管 string、类、数组,只是编译器在检查,不改变程序运行。

C#
1string s = null;// 编辑器给出绿色波浪线:警告 CS8600
2string? t = null;// 加了问号,就没有警告
3Console.WriteLine(t == null);// 打印 True
4Console.WriteLine(s.Length);// 警告只是提醒,程序照样运行,到这一行抛出空引用异常

运行先打印 True,再抛出 System.NullReferenceException。

可空值类型是 C# 2.0 加入的。可空引用类型的检查,现在新建的项目默认就已经打开,不用自己启用。

下一节:想存全班五十个人的成绩,难道要声明五十个变量吗?

检查题

5 道,选完马上告诉你对不对。

  1. 1int a = null; 会怎样?

  2. 2int? x = null; int y = x ?? 5; y 是?

  3. 3int? x = null; x ??= 10; x ??= 20; 最后 x 是?

  4. 4int? n = null; int v = n.Value; 运行结果?

  5. 5方法里写 int i; Console.WriteLine(i); 会怎样?