Lesson 01: C# 8 —— 告别空引用异常的时代

从 .NET Framework 4.8 / C# 7.3 出发,进入现代 C# 的第一个大版本

你的位置 / Your baseline:你已经掌握 C# 7.3(可空值类型Nullable Value Types、表达式体成员Expression-bodied members、模式匹配Pattern matching 入门)。
本课目标 / Goal:理解 C# 8 最重要的变革——可空引用类型Nullable Reference Types, NRT,以及为什么它是现代 C# 的基石。
📎 快速回顾:C# 7.3 已经有的三个东西

可空值类型Nullable Value Types——C# 2.0 就有了:

int? score = null;                  // 语法糖,底层是 Nullable<int>
int result = score ?? -1;             // null-coalescing(C# 2)
int result2 = score.HasValue ? score.Value : -1;  // 等价

表达式体成员Expression-bodied Members——=> 语法糖,C# 6 引入方法/只读属性,C# 7 扩展到构造/析构/访问器:

public string FullName => $"{FirstName} {LastName}";
public Person(string name) => _name = name;   // C# 7 新增

模式匹配Pattern Matching——C# 7 把类型检查 + 转换合为一步,C# 8 后大幅强化:

// C# 6:检查 + 转换两步
if (obj is string) { var s = (string)obj; ... }

// C# 7:一步到位
if (obj is string s && s.Length > 0) ...

一、先看全貌:Framework 4.8 之后发生了什么

2018
FW 4.7.2
C# 7.3
2019
FW 4.8 / Core 3.0
C# 8
2020
.NET 5
C# 9
2021
.NET 6 LTS
C# 10
2022
.NET 7
C# 11
2023
.NET 8 LTS
C# 12
2024
.NET 9
C# 13
2025
.NET 10 LTS
C# 14

关键转折:

重要概念:统一后的 .NET 不再叫 ".NET Core"——就叫 ".NET"。见人说 .NET 6,不是 .NET Core 6。

二、引入 Nullable Reference Types

问题:你可能已经写过上万行 NullReferenceException

C# 7.3(Framework 4.8)

string name = GetName();
int length = name.Length; // 💥 如果 name 是 null
// 编译完全通过,运行时爆炸

string? nullableValue; // ← 只对 value type 有效
// int? = Nullable<int>
// string? 不存在!(C# 7.3)

C# 8+(Nullable 开启)

string name = GetName();      // 非 null 的承诺
int length = name.Length;    // ✅ 编译器放心

string? maybeNull = GetName(); // 可以为 null
// int len = maybeNull.Length; // ⚠️ 编译器警告!
// int len = maybeNull!.Length // 我知道它不是 null(断言)

核心思想:引用类型Reference Types 的 null 不再是运行时Runtime问题,而变成编译时Compile-time问题。

用 TypeScript 类比:C# 8 的 NRT 就像 TypeScript 的 strictNullChecks——在编译期区分 string(非 null)和 string?(可能 null)。

开启方式

在你的 .csproj 或文件顶部控制:

// 方式 1:项目级别(.csproj)
<Nullable>enable</Nullable>

// 方式 2:文件级别(放在文件最顶部)
#nullable enable
#nullable disable    // 局部关闭
#nullable restore    // 恢复到项目设置
建议:新项目直接在 .csproj 中 <Nullable>enable</Nullable>。旧项目可以逐文件迁移。

核心规则:四个操作符Operators,两个场景Scenarios

写法 / Syntax含义 / Meaning编译器行为 / Compiler Behavior
string name "这个引用不会为 null" 如果你把它设为 null,警告
string? name "这个引用可以为 null" 如果你直接访问它的成员,警告
name! "我断言它不是 null"(空包容操作符Null-forgiving Operator 抑制所有警告——相信自己,风险自负
name!.Foo ! 作用在 name 上,.Foo 是普通成员访问 ! 不是和 . 绑定的操作符——它俩独立,只是挨在一起

编译器如何追踪 null 状态 Null-state Analysis

编译器会做流分析Flow Analysis,追踪变量在每条路径上的 null 状态Null State

string? name = GetMaybeNullName();

// ❌ 编译器警告:name 可能为 null
int a = name.Length;

// ✅ 编译器不警告:你在 if 里检查过
if (name != null)
{
    int b = name.Length;  // name 在此分支内被追踪为 "not null"
}

// ✅ 也可以用 throw 来帮助编译器推断
if (name == null) throw new ArgumentNullException(nameof(name));
int d = name.Length;  // 编译器知道这下面 name 一定不是 null

// ✅ null-coalescing 也会被追踪
int e = (name ?? "").Length;

最该记住的模式

C# 7.3:防御式编程Defensive Programming

public string Format(string input)
{
    if (input == null)
        throw new ArgumentNullException(
            nameof(input));

    return input.ToUpper();
    // 你只是"自觉"保证不返回 null
}

C# 8:类型系统替你做

public string Format(string input)
{
    return input.ToUpper();
    // 1. 非 null 签名就是契约
    // 2. 调用方传 null 时编译器会警告
    // 3. 你不需要手动 throw
}
注意:NRT 是编译时检查,不是运行时保证。JIT 不会插入 null 检查。如果通过反射或者关掉 nullable 的代码调用你,null 仍然会进入你的方法。这就是为什么 BCL 中的公开 API 仍然保留显式的 ArgumentNullException.ThrowIfNull 检查(.NET 6+ 新增的)。

三、配套特性:??= 空合并赋值Null-coalescing Assignment

// C# 7.3: 检查 + 赋值 两步走
if (name == null)
    name = "default";

// C# 8: 一步
name ??= "default";

// 和 ?? 一样是短路求值——只有左边是 null 才执行右边
list ??= new List<string>();

四、实战场景与边界情况 Real-world Scenarios & Edge Cases

场景 1:编译器流分析Flow Analysis 对构造函数的宽松处理

开了 NRT 后,编译器会检查所有非 null 字段是否初始化。但它在构造函数上做特殊分析——只要字段在构造函数体中被赋了值(不管是 DI 传进来还是直接 new),它就不报警告

public class UserService
{
    // 构造函数内一定被赋值 → 编译器追踪到了 → 不警告
    private ILogger _logger;
    private DbContext _db;

    public UserService(ILogger logger, DbContext db)
    {
        _logger = logger;
        _db = db;
    }
}
注意:如果你的项目没开 #nullable enable,编译器完全不追踪 null 状态,这段代码和你 Framework 4.8 的体验完全一样。这个场景只有在 NRT 开启时才需要关心。

场景 2:EF Core 的导航属性Navigation Properties

public class Order
{
    public int Id { get; set; }

    // EF Core 通过反射填充,编译器认为它没有初始化
    // 用 = null! 告诉编译器"我知道它会被填充"
    public Customer Customer { get; set; } = null!;

    // 或标记为 required (C# 11)
    // public required Customer Customer { get; set; }
}
null! 拆解——它是两个独立的东西叠加:
null 给字段一个初始值(满足编译器"非 null 字段必须初始化"的要求);
! 马上告诉编译器"别分析这个 null 赋值"(空包容操作符Null-forgiving Operator)。

两者没有任何语法绑定——! 不是 null! 专用,它可以跟在任何表达式后面。这里的本质就是:"我先写个 null,编译器你别管,反射会在运行时塞真正的对象进来。"

场景 3:TryGetValue 模式Try-Parse Pattern

// 传统 TryXxx 模式——out 参数在 NRT 下的签名
public bool TryGetUser(int id, [NotNullWhen(true)] out User? user)
{
    user = _users.FirstOrDefault(u => u.Id == id);
    return user != null;
}

// 调用侧——编译器理解 NotNullWhen
if (TryGetUser(42, out var user))
{
    string name = user.Name; // ✅ 编译器知道这里 user 不是 null
}

[NotNullWhen(true)] 是 C# 8 引入的 null-state 分析 attribute,让编译器理解"如果方法返回 true,out 参数就不是 null"。

五、迁移旧项目的策略 Migration Strategy

  1. 先开 warnings,不开 enable:<Nullable>warnings</Nullable>——有提示但没编译错误
  2. 逐文件迁移:在文件顶部加 #nullable enable,修完一个文件再下一个
  3. 公共 API 优先:先修 library 项目的公开接口,再修 internal 代码
  4. 善用 null!对于 DI、EF Core 等"我知道它不是 null 但编译器不知道"的场景

六、NRT 现在用得怎么样了?Adoption Status

2026 年视角:NRT 不再被热议——因为它已经是默认了。

为什么你感觉没人提?两个原因:
1. 讨论期(2019-2021)早已过去,现在它和 async/await 一样属于"这还用聊?"的基础设定。
2. .NET 6 起所有官方项目模板默认带 <Nullable>enable</Nullable>,新项目从出生就开着。

实际使用情况:BCL 全量标注、EF Core / ASP.NET Core / 主流 NuGet 包全部跟进。面试中 NRT 不会被当作"新特性"来问——它被当成默认存在的背景。面试官可能直接写 string? 然后问你这行有什么问题。

📝 小测验

Q1. 以下代码有几处编译警告?

#nullable enable

string? a = null;
string b = a;
string? c = "hello";
int len1 = c.Length;
int len2 = c!.Length;
string d = c!;
选择正确答案:

Q2. 以下哪种写法是"我断言它不是 null"?

选择正确答案:

Q3. NRT 给你的保障是?

选择正确答案: