浏览知识库目录

C#

可空引用、异常与资源安全

使用可空引用分析、明确异常和 using 管理空值、失败语义与资源生命周期。

可空引用、异常与资源安全

可靠程序必须明确三件事:哪些值可以缺失,哪些失败允许调用方恢复,谁负责释放资源。可空引用分析、异常和 using 分别处理这些问题,但它们都需要真实的边界设计,不能只靠语法消除警告。


一、学习目标

  • 理解可空引用注解和流分析
  • 在外部边界执行运行时空值校验
  • 设计清晰的领域异常与异常链
  • 缩小 try 块并捕获具体异常
  • 使用 usingawait 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 文档和类型设计应明确所有权。


十一、练习与自测

练习:

  1. 为配置 Token 加入明确的缺失错误,删除空抑制。
  2. 定义 TaskStorageException 并保留 SQLite 原因链。
  3. 用一个会抛异常的测试证明 using 仍释放资源。
  4. 为同步命令区分用户取消、超时和网络错误。

自测:

  • string 注解能否阻止运行时传入 null?
  • throw;throw exception; 有何区别?
  • 声明式 using var 在何时释放?
  • 为什么取消异常通常不应在底层吞掉?

十二、官方资料

上一篇:类、接口、记录与对象建模 | 下一篇:委托、事件、Lambda 与扩展成员