可空引用类型
大约 11 分钟约 3154 字
可空引用类型
简介
可空引用类型(Nullable Reference Types,简称 NRT)是 C# 8.0 引入的编译时特性。它通过类型系统显式标注引用类型是否可以为 null,帮助开发者在编译阶段发现潜在的 NullReferenceException。启用后,所有引用类型默认不可为 null,需要用 ? 标注可空。
特点
启用方式
<!-- csproj 中启用 -->
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
<!-- 或按文件控制 -->
#nullable enable // 启用
#nullable disable // 禁用
#nullable restore // 恢复项目设置基本概念
不可空 vs 可空
#nullable enable
/// <summary>
/// 启用 NRT 后的行为变化
/// </summary>
public class UserService
{
// 不可空引用类型 — 默认,不能赋 null
private string _name = "默认值"; // OK
// private string _name2 = null; // 警告!CS8600
// 可空引用类型 — 用 ? 标注
private string? _nickname = null; // OK
public void Process(string name, string? email)
{
// name 不可为 null,编译器信任你
Console.WriteLine(name.Length); // 无警告
// email 可能为 null,需要检查
if (email != null)
{
Console.WriteLine(email.Length); // 无警告
}
// Console.WriteLine(email.Length); // 警告!CS8602
}
}编译器警告类型
#nullable enable
/// <summary>
/// 常见 NRT 警告
/// </summary>
public class WarningExamples
{
// CS8618 — 不可空属性未在构造函数中初始化
// public string Name { get; set; } // 警告!
// 解决方式1:init 或 默认值
public string Name { get; init; } = "";
public string Title { get; init; } = "";
// 解决方式2:构造函数赋值
public string Department { get; set; }
public WarningExamples()
{
Department = "未分配";
}
// 解决方式3:标注可空
public string? Description { get; set; }
// 解决方式4:= null! 强制(告诉编译器我知道)
public string Remark { get; set; } = null!;
}方法参数与返回值
参数可空标注
/// <summary>
/// 方法签名的可空标注
/// </summary>
public class SearchService
{
// name 不可为 null,keyword 可为 null
public List<User> Search(string name, string? keyword = null)
{
var query = _users.Where(u => u.Name == name);
// keyword 可能为 null,需要条件过滤
if (!string.IsNullOrEmpty(keyword))
{
query = query.Where(u => u.Email.Contains(keyword));
}
return query.ToList();
}
}
// 调用方也能看到提示
service.Search("张三"); // OK,keyword 可选
service.Search("张三", "test@"); // OK
// service.Search(null!, "test@"); // 警告!name 不能为 null返回值可空标注
/// <summary>
/// 返回值标注 — 明确表达是否返回 null
/// </summary>
public class UserRepository
{
// 明确表示可能返回 null
public User? FindById(int id)
{
return _users.FirstOrDefault(u => u.Id == id); // 可能返回 null
}
// 明确表示不会返回 null
public User GetById(int id)
{
return _users.First(u => u.Id == id); // 找不到抛异常,不返回 null
}
// TryGet 模式
public bool TryGet(int id, [NotNullWhen(true)] out User? user)
{
user = _users.FirstOrDefault(u => u.Id == id);
return user != null;
}
}可空注解特性
/// <summary>
/// 条件注解特性 — 精确表达 null 语义
/// </summary>
// [AllowNull] — 参数可以为 null,即使类型不可空
public class StringUtils
{
[AllowNull]
public static void Process(ref string text)
{
text ??= "默认值"; // 允许传入 null
}
}
// [NotNull] — 参数不可为 null,即使类型是可空的
public static void EnsureNotNull([NotNull] ref string? value)
{
value ??= "默认值";
}
// [NotNullWhen(true/false)] — 返回值决定参数是否为 null
public static bool IsNullOrEmpty([NotNullWhen(false)] string? value)
{
return string.IsNullOrEmpty(value);
}
// 使用时编译器知道检查后不为 null
string? input = GetUserInput();
if (!IsNullOrEmpty(input))
{
Console.WriteLine(input.Length); // 无警告!编译器知道 input 不为 null
}
// [MaybeNullWhen(true)] — 返回 true 时参数可能为 null
public static bool TryParse(string? input, [MaybeNullWhen(false)] out int result)
{
return int.TryParse(input, out result);
}泛型中的可空
/// <summary>
/// 泛型可空约束
/// </summary>
// T 默认是不可空引用类型
public class Repository<T> where T : class
{
// T? 表示可空
public T? Find(int id) => _items.FirstOrDefault(i => i.Id == id);
// T 表示不可空
public T Get(int id) => _items.First(i => i.Id == id);
}
// default 约束 — 允许值类型的 default
public T GetOrDefault<T>(int id) where T : default
{
return _items.TryGetValue(id, out var item) ? item : default!;
}
// class? 约束 — 允许可空引用类型
public class Optional<T> where T : class?
{
private readonly T? _value;
public Optional(T? value) => _value = value;
public bool HasValue => _value != null;
public T Value => _value ?? throw new InvalidOperationException();
}EF Core 中的可空处理
/// <summary>
/// EF Core 实体的可空标注
/// </summary>
public class Product
{
public int Id { get; set; }
// 必填字段 — 不可空
public string Name { get; set; } = null!; // 用 null! 避免 CS8618
// 可选字段 — 可空
public string? Description { get; set; }
// 外键 — 可空表示可选关联
public int? CategoryId { get; set; }
public Category? Category { get; set; }
// 金额 — 不可空
public decimal Price { get; set; }
// 导航属性 — 集合不可空
public List<OrderItem> OrderItems { get; set; } = new();
}
// DbContext 中配置可空
public class AppDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>(entity =>
{
entity.Property(e => e.Name).IsRequired(); // NOT NULL
entity.Property(e => e.Description).IsRequired(false); // NULL OK
entity.Property(e => e.Price).IsRequired();
});
}
}迁移现有项目启用 NRT
渐进式迁移策略
/// <summary>
/// 现有项目启用 NRT 的步骤
/// </summary>
// 步骤 1:启用全局 NRT
// <PropertyGroup>
// <Nullable>enable</Nullable>
// </PropertyGroup>
// 步骤 2:按模块修复警告
// 建议从底层库开始,自底向上修复
// 步骤 3:常见的修复模式
public class MigrationExamples
{
// 修复 CS8618 — 非空属性未初始化
// ❌ 编译警告
// public string Name { get; set; }
// 修复方式 1:默认值
public string Name { get; set; } = "";
// 修复方式 2:构造函数初始化
public string Email { get; set; }
public MigrationExamples(string email) => Email = email;
// 修复方式 3:标注可空
public string? OptionalField { get; set; }
// 修复方式 4:= null!(仅在确定初始化时机的情况下)
public string InitializedLater { get; set; } = null!;
// 修复方式 5:required(C# 11)— 强制调用方初始化
public required string MandatoryField { get; set; }
// 修复 CS8602 — 可能的 null 解引用
public void ProcessUser(string? name)
{
// ❌ 警告
// Console.WriteLine(name.Length);
// 修复方式 1:null 检查
if (name != null)
Console.WriteLine(name.Length);
// 修复方式 2:null 合并运算符
Console.WriteLine((name ?? "").Length);
// 修复方式 3:模式匹配
if (name is { Length: > 0 })
Console.WriteLine(name.Length);
// 修复方式 4:! 断言(谨慎使用)
Console.WriteLine(name!.Length); // 告诉编译器"我确定不为 null"
}
}处理第三方库的 NRT 警告
/// <summary>
/// 第三方库未启用 NRT 时的处理
/// </summary>
// 场景:调用的第三方库方法返回 string,但未标注可空性
// 编译器不知道它可能返回 null
// 解决方案 1:使用 #pragma 禁用特定警告
#pragma warning disable CS8602 // 可能的 null 引用
var result = ThirdPartyLib.GetData();
#pragma warning restore CS8602
if (result != null) { /* ... */ }
// 解决方案 2:使用 null 检查包装
public static T AssertNotNull<T>([NotNull] T? value, string message = "")
{
if (value is null)
throw new InvalidOperationException(message);
return value;
}
// 使用
var data = AssertNotNull(ThirdPartyLib.GetData());
// data 的类型从 T? 提升为 T
// 解决方案 3:为第三方库创建可空注解文件
// 在项目中创建 Directory.Build.props
// 或者等待库作者启用 NRT最佳实践
| 场景 | 建议 |
|---|---|
| 方法参数 | 必填用 string,可选用 string? |
| 返回值 | 可能没值用 T?,保证有值用 T |
| EF 实体属性 | 必填 = null!,选填 ? |
| 集合属性 | 用 List<T> 并初始化 = new() |
| DTO | 启用 NRT,用 ? 标注可选字段 |
| 泛型 | 注意 where T : class vs where T : class? |
优点
缺点
NRT 与设计模式
Null Object 模式
/// <summary>
/// 使用 Null Object 模式减少 null 检查
/// </summary>
// 传统方式 — 到处检查 null
public interface ILogger
{
void Log(string message);
}
public class ConsoleLogger : ILogger
{
public void Log(string message) => Console.WriteLine(message);
}
// ❌ 返回 null,调用方需要检查
ILogger? GetLogger(string type) => type switch
{
"console" => new ConsoleLogger(),
_ => null
};
// ✅ Null Object 模式 — 返回"什么都不做"的实现
public class NullLogger : ILogger
{
public void Log(string message) { /* 什么都不做 */ }
}
ILogger GetLoggerSafe(string type) => type switch
{
"console" => new ConsoleLogger(),
_ => new NullLogger() // 永远不返回 null
};
// 使用时无需 null 检查
ILogger logger = GetLoggerSafe("unknown");
logger.Log("message"); // 安全,不会抛异常Maybe / Option 模式
/// <summary>
/// 使用泛型 Option 类型替代 null
/// </summary>
public readonly struct Option<T>
{
private readonly T _value;
private readonly bool _hasValue;
private Option(T value, bool hasValue)
{
_value = value;
_hasValue = hasValue;
}
public static Option<T> Some(T value) => new(value, true);
public static Option<T> None() => new(default!, false);
public bool HasValue => _hasValue;
public T Value => _hasValue ? _value : throw new InvalidOperationException("No value");
public TResult Match<TResult>(Func<T, TResult> some, Func<TResult> none)
=> _hasValue ? some(_value) : none();
public Option<TResult> Map<TResult>(Func<T, TResult> mapper)
=> _hasValue ? Option<TResult>.Some(mapper(_value)) : Option<TResult>.None();
// 使用示例
public static Option<int> Divide(int a, int b)
=> b != 0 ? Option<int>.Some(a / b) : Option<int>.None();
public static Option<T> Find<T>(IEnumerable<T> source, Func<T, bool> predicate)
{
foreach (var item in source)
if (predicate(item)) return Option<T>.Some(item);
return Option<T>.None();
}
}
// 使用 — 无 null,编译时安全
var result = Option<int>.Divide(10, 3);
string message = result.Match(
some: value => $"结果: {value}",
none: () => "不能除以零"
);
// 链式操作
var final = Option<int>.Divide(10, 2)
.Map(x => x * 3)
.Map(x => x + 1);
// final = Some(16)NRT 在测试中的使用
/// <summary>
/// 在单元测试中利用 NRT 发现问题
/// </summary>
// NRT 帮助发现测试中遗漏的 null 场景
public class UserServiceTests
{
[Fact]
public void FindById_WithInvalidId_ReturnsNull()
{
var service = new UserService();
User? result = service.FindById(-1);
// NRT 提醒你 result 是 User?,需要检查
Assert.Null(result);
}
[Fact]
public void FindById_WithValidId_ReturnsUser()
{
var service = new UserService();
User? result = service.FindById(1);
// NRT 提醒你 result 可能为 null
Assert.NotNull(result);
// 下面这行需要先 Assert.NotNull
Assert.Equal("Alice", result.Name);
}
// 测试 null 参数的行为
[Fact]
public void Process_WithNullName_ThrowsArgumentNullException()
{
var service = new UserService();
// NRT 让编译器警告你传入了 null
Assert.Throws<ArgumentNullException>(() => service.Process(null!));
}
}总结
可空引用类型是 C# 最重要的安全特性之一。新项目应默认启用,老项目可逐步迁移。核心原则:不可空的用 T,可空的用 T?,用注解特性精确表达 null 语义。
关键知识点
- 先明确这个主题影响的是语法层、运行时层,还是性能与可维护性层。
- 学习时要同时关注语言表面写法和编译器、JIT、GC 等底层行为。
- 真正有价值的是知道“为什么这样写”和“在什么边界下不能这样写”。
项目落地视角
- 把示例改成最小可运行样例,并观察编译输出、运行结果和异常行为。
- 如果它会进入团队代码规范,最好同步补充命名约定、禁用场景和替代方案。
- 涉及性能结论时,优先用 Benchmark 或实际热点链路验证,而不是凭感觉判断。
常见误区
- 只记语法糖,不知道底层成本。
- 把适用于小样例的写法直接搬到高并发或大对象场景里。
- 忽略框架版本、语言版本和运行时差异,导致结论失真。
进阶路线
- 继续向源码、IL、JIT 行为和 BCL 实现层深入。
- 把知识点和代码评审、性能诊断、面试复盘结合起来。
- 把同类主题做横向对比,例如值类型与引用类型、迭代器与 async 状态机、反射与 Source Generator。
适用场景
- 当你准备把《可空引用类型》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合在需要理解语言特性、运行时行为或 API 边界时阅读。
- 当代码开始出现性能瓶颈、可维护性问题或语义歧义时,这类主题会直接影响实现质量。
落地建议
- 先写最小可运行样例,再把结论迁移到真实业务代码。
- 同时记录这个特性的收益、限制和替代方案,避免为了“高级”而使用。
- 涉及内存、并发或序列化时,最好配合调试器或基准测试验证。
排错清单
- 先确认问题属于编译期、运行期还是语义误用。
- 检查是否存在隐式转换、装箱拆箱、闭包捕获或上下文切换等隐藏成本。
- 查看异常栈、日志和最小复现代码,优先排除使用姿势问题。
复盘问题
- 如果把《可空引用类型》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《可空引用类型》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《可空引用类型》最大的收益和代价分别是什么?
