团队一个老项目从 .NET Framework 迁到 .NET 8 的时候顺手把 Nullable 引用类型打开了,本以为就是处理一堆编译警告的体力活,结果中间几个地方的行为跟直觉不太一样,记一下。

打开方式很简单,项目文件里加一行:

1
<Nullable>enable</Nullable>

打开之后编译器立刻甩出来几百个警告,CS8618CS8602 满屏都是。第一反应是挨个加 ?,遇到警告就把类型改成可空,图快:

1
2
3
4
public class Order
{
public string CustomerName { get; set; } // CS8618: 未初始化的非空属性
}

改成:

1
public string? CustomerName { get; set; }

这样警告确实是没了,但这是最偷懒也最没意义的做法。加了 ? 之后编译器倒是不吼了,可后面所有用到 CustomerName 的地方理论上都得处理 null 分支,实际上团队里很多人图省事直接用了 !(null 包容操作符)糊弄过去:

1
Console.WriteLine(order.CustomerName!.ToUpper());

这行代码等于是骗编译器说”我保证这里不是 null”,但没有任何运行时校验,真要是 null,NullReferenceException 照样炸,跟没开这个功能之前没有本质区别,纯粹是把警告消掉了,风险原封不动地留在那。回头 code review 的时候把这类 ! 全揪出来重新审视了一遍,大部分情况其实是能在构造函数里强制要求传入这个字段,从根上让它不可能是 null,而不是在用的时候硬 ! 过去:

1
2
3
4
5
6
7
8
9
public class Order
{
public Order(string customerName)
{
CustomerName = customerName;
}

public string CustomerName { get; set; }
}

第二个容易让人意外的地方是可空引用类型只是编译期的静态检查,没有任何运行时保证,跟 Nullable<T>(也就是 int? 那种值类型的可空)完全是两套东西。这意味着如果这个类型的数据是从外部反序列化进来的(比如 JSON 反序列化、数据库读出来的),编译器完全没办法在编译期知道这个值到底是不是真的非 null,它只会相信你写的类型声明:

1
2
3
4
5
6
7
public class UserDto
{
public string Name { get; set; } = null!; // 反序列化会覆盖这个默认值,但编译器不知道
}

var user = JsonSerializer.Deserialize<UserDto>(json);
Console.WriteLine(user.Name.Length); // 如果 JSON 里没有 Name 字段,这里运行时直接炸

反序列化如果 JSON 里缺了 Name 字段,user.Name 实际上就是 null,但因为类型声明是非空的 string,编译器完全不会给出任何警告,user.Name.Length 一执行就是 NullReferenceException。这算是打开这个功能之后最容易产生”虚假安全感”的地方——看到类型是非空的就下意识觉得它一定有值,但凡数据来源是反序列化或者外部输入,这层信任是不成立的,这些边界上原来该做的 null 校验一个都不能少,可空引用类型解决不了外部输入的问题,它只能帮你在纯内部代码逻辑里揪出遗漏的 null 处理。

第三个是泛型和第三方库交互的地方,有些老的 NuGet 包还没适配可空标注(也就是没有 nullable annotations),引用进来的类型全部被当成”unknown nullability”,警告该出的地方不出,容易让人误以为这块已经安全了,实际上只是编译器压根不知道该不该警告。升级依赖版本、看包有没有加 nullable 标注支持是这块能做的事情,但短期内很多包还没跟上,这种边界只能靠人工留意。

整体上开可空引用类型这件事本身是值得做的,能在编译期揪出不少以前只能靠运行时炸出来才知道的 null 问题,但心态上不能指望开了这个开关就万事大吉,遇到警告图快随手加 ? 或者 ! 打发,跟没开效果差不多,真正有价值的是借这个机会把该有值、不该为 null 的字段在设计层面就做扎实(构造函数强制赋值、加校验),而不是把警告当成纯粹要消灭的噪音。