C# 可空引用类型:把空值风险提前暴露

介绍 nullable reference types 的使用方式、常见警告和 API 设计思路,让 null 从运行时异常变成编译期提示。

NullReferenceException 是很多 C# 项目里最常见也最无聊的错误之一。可空引用类型的价值,就是把“这里可能是 null”这件事提前放到编译期讨论。

开启可空分析

在项目文件中启用:

<PropertyGroup>
  <Nullable>enable</Nullable>
</PropertyGroup>

开启后,引用类型默认被视为不可为 null:

string name = null; // 编译器警告

如果某个值确实可能为空,需要显式写成 string?

string? nickname = FindNickname(userId);

这个问号不是运行时特性,而是给编译器和读代码的人看的约定:这里必须处理空值。

TT? 是 API 语义

方法签名里是否带 ?,就是调用方和实现方之间的契约。

public User? FindUser(Guid id)
{
    return users.TryGetValue(id, out var user) ? user : null;
}

这个签名告诉调用方:用户可能不存在,请处理。

var user = FindUser(id);

if (user is null)
{
    return NotFound();
}

return Ok(user.Name);

反过来,如果一个方法返回 User,就应该尽力保证它不会返回 null。否则类型签名就在撒谎。

不要滥用 !

空值宽恕操作符 ! 可以告诉编译器“我确定这里不是 null”。

var name = user!.Name;

它不会改变运行时行为。如果 user 实际上是 null,程序仍然会抛异常。

! 适合用于编译器无法理解、但你能证明安全的场景,例如测试初始化、框架注入或特定验证流程之后。它不适合用来压掉所有警告。

构造函数和必填属性

启用 nullable 后,类的非空属性需要在构造时被初始化。

public sealed class Article
{
    public Article(string title)
    {
        Title = title;
    }

    public string Title { get; }
}

如果对象需要通过对象初始化器创建,可以使用 required 表达“调用方必须赋值”。

public sealed class Article
{
    public required string Title { get; init; }
    public string? Summary { get; init; }
}

这样比把所有属性都写成 string? 更准确,因为它区分了“必填但稍后初始化”和“真的可以为空”。

集合也要表达空值

下面两个类型含义不同:

List<string>? names
List<string?> names

第一个表示整个列表可能为空;第二个表示列表存在,但里面的元素可能为空。写 API 时要尽量表达清楚。

小结

可空引用类型不是为了让代码多几个问号,而是让空值成为设计的一部分。

一个实用习惯是:

  • 能不返回 null,就不要返回 null。
  • 可能不存在的结果,用 T? 明确表达。
  • 输入参数如果不允许 null,就保持非空类型。
  • 少用 !,多用判断和清晰的初始化。

当类型签名讲真话,调用方就不需要靠猜。