C#
可空引用、异常与资源安全
使用可空引用分析、明确异常和 using 管理空值、失败语义与资源生命周期。
发布于 2026年7月23日
可空引用、异常与资源安全
可靠程序必须明确三件事:哪些值可以缺失,哪些失败允许调用方恢复,谁负责释放资源。可空引用分析、异常和 using 分别处理这些问题,但它们都需要真实的边界设计,不能只靠语法消除警告。
一、学习目标
- 理解可空引用注解和流分析
- 在外部边界执行运行时空值校验
- 设计清晰的领域异常与异常链
- 缩小 try 块并捕获具体异常
- 使用
using、await using管理资源 - 明确资源所有权与取消语义
二、可空引用是编译期分析
启用:
<Nullable>enable</Nullable>
之后 string 表示调用契约不接受空值,string? 表示可能为空:
static int TitleLength(string title) => title.Length;
static string Display(string? title) =>
string.IsNullOrWhiteSpace(title) ? "(无标题)" : title.Trim();
编译器通过分支跟踪状态,但不会在运行时自动阻止反射、旧程序集、反序列化或数据库返回 null。公开边界仍要校验:
public TaskService(ITaskRepository repository)
{
ArgumentNullException.ThrowIfNull(repository);
_repository = repository;
}
注解是契约与分析,不是数据清洗器。
三、流分析和模式
StudyTask? task = repository.Find(id);
if (task is null)
{
return null;
}
Console.WriteLine(task.Title); // 已被证明非空
属性模式可同时检查对象与成员:
if (task is { CompletedAt: { } completedAt })
{
Console.WriteLine(completedAt.ToString("O"));
}
空条件运算符适合安全读取:
int length = task?.Title.Length ?? 0;
C# 14 还允许空条件赋值:
task?.LastViewedAt = timeProvider.GetUtcNow();
只有接收者非空时才执行赋值。它适合可选更新,但若“任务不存在”应该报错,就不能用它静默跳过。
四、慎用空抑制运算符
string token = configuration["ApiToken"]!;
! 只告诉编译器“相信我”,不会加入运行时检查。配置缺失时错误会在更远的位置变成 NullReferenceException。
更可靠:
string token = configuration["ApiToken"]
?? throw new InvalidOperationException("缺少 ApiToken 配置");
仅在框架生命周期确实超出分析能力,并且不变量有其他机制保证时使用 !,同时用注释或测试说明依据。
五、异常是契约的一部分
常见选择:
- 参数不符合方法前置条件:
ArgumentException及派生类型 - 对象当前状态不允许操作:
InvalidOperationException - 找不到领域实体:自定义
TaskNotFoundException - I/O、数据库、HTTP 失败:保留原异常或转换为边界异常
- 取消:
OperationCanceledException
public sealed class TaskNotFoundException(int id)
: Exception($"任务 {id} 不存在")
{
public int TaskId { get; } = id;
}
自定义异常应提供调用方能使用的结构化信息,但不要把令牌、连接字符串或完整响应体放进消息。
六、捕获具体异常
try
{
repository.Update(task);
}
catch (SqliteException exception) when (exception.SqliteErrorCode == 5)
{
throw new TaskStorageException(
"数据库暂时繁忙,请稍后重试",
exception);
}
异常筛选器只处理已理解的错误。throw new ... (message, exception) 保留原因链;如果只是原样继续传播,使用 throw;,不要使用 throw exception; 重置堆栈。
缩小 try 范围:
string text;
try
{
text = File.ReadAllText(path);
}
catch (FileNotFoundException)
{
return [];
}
return ParseTasks(text);
这样解析缺陷不会被误判为文件缺失。
七、finally 与 using
实现 IDisposable 的对象通常通过 using:
using var connection = new SqliteConnection(connectionString);
connection.Open();
using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT id, title FROM task;";
编译器会生成等价的 try/finally,即使异常发生也调用 Dispose。声明式 using var 的释放时刻是当前作用域结束;资源应尽量放在小作用域中,不要让连接意外存活到方法末尾。
异步资源:
await using Stream stream = await OpenExportAsync(cancellationToken);
await JsonSerializer.SerializeAsync(
stream,
tasks,
cancellationToken: cancellationToken);
await using 调用 DisposeAsync,适合释放过程本身需要异步等待的资源。
八、资源所有权
创建资源的一方通常负责释放,除非 API 明确转移所有权。
public sealed class TaskClient(HttpClient httpClient)
{
public Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken) =>
httpClient.SendAsync(request, cancellationToken);
}
由依赖注入提供的 HttpClient 不应被每次调用自行释放;容器或工厂管理其生命周期。方法内部创建的 HttpRequestMessage 与收到的 HttpResponseMessage 则由当前操作负责释放。
不要把仍会被异步操作使用的流提前释放,也不要把需要释放的对象藏进无生命周期说明的静态字段。
九、取消不是普通失败
try
{
await client.SyncAsync(cancellationToken);
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)
{
Console.Error.WriteLine("操作已取消");
return 130;
}
只在当前令牌确实被请求取消时,把异常转换为取消结果。超时也可能以取消异常表现,但业务含义不同,应由拥有超时策略的边界分类。
不要捕获所有 Exception 后返回成功,也不要在底层吞掉 OperationCanceledException,否则上层无法及时停止。
十、常见错误
用 ! 消灭所有警告
警告消失不代表空值消失。应修复数据流或增加运行时校验。
catch (Exception) 后继续
程序状态可能已经不可信。顶层可以记录并转换退出码,但领域层只处理理解的失败。
throw exception
会改变堆栈起点。重新抛出使用 throw;,转换异常时保留 inner exception。
不清楚谁释放资源
重复释放通常可容忍,泄漏却会耗尽句柄或连接。API 文档和类型设计应明确所有权。
十一、练习与自测
练习:
- 为配置 Token 加入明确的缺失错误,删除空抑制。
- 定义
TaskStorageException并保留 SQLite 原因链。 - 用一个会抛异常的测试证明
using仍释放资源。 - 为同步命令区分用户取消、超时和网络错误。
自测:
string注解能否阻止运行时传入 null?throw;与throw exception;有何区别?- 声明式
using var在何时释放? - 为什么取消异常通常不应在底层吞掉?
十二、官方资料
上一篇:类、接口、记录与对象建模 | 下一篇:委托、事件、Lambda 与扩展成员