首页 / C# 入门教程 / 自定义异常

C# 入门教程

自定义异常

本教程共 100 篇 · 第 75 篇 · 更新于 2026-07-31 · 约 10 分钟阅读

C#C# 入门教程异常处理自定义异常Exception

75. 自定义异常

本节目标:学完你能从 Exception 派生出自己的异常类型,命名规范、携带业务字段,并写出标准构造函数。

内置的 FormatException、ArgumentNullException 已经能覆盖大多数场景。但当你有一套专属业务规则时——比如年龄越界、账户余额不足——用一个自己命名的异常,能让调用方一眼看懂出了什么错,也能顺手带上关键数据。

为什么需要自定义异常

内置异常表达的是”通用语义”:参数为空、格式不对、下标越界。但它们说不清”业务上到底怎么了”。想象一个电商系统,支付失败用 InvalidOperationException 也能抛,但调用方没法一眼区分”余额不足”还是”商品已下架”——而这两件事用户该看到完全不同的提示。

自定义异常的真正价值是给错误一个”可被精确识别的身份”。调用方可以用 catch (InsufficientBalanceException) 专门处理余额不足,用 catch (OutOfStockException) 处理缺货,互不干扰。它把”业务语义”写进了类型系统,而不是埋在消息字符串里。

Note

自定义异常本质仍是一个类,继承自 Exception(或其子类)。它生来就带有 MessageStackTraceInnerException 等所有异常能力,你只是额外加了业务字段和方法。

继承 Exception,命名以 Exception 结尾

自定义异常就是一个普通类,继承自 System.Exception(也可以继承更具体的类型)。按约定,类名必须以 Exception 结尾,比如 AgeOutOfRangeExceptionInsufficientBalanceException

namespace CSharpDemo;

class AgeOutOfRangeException : Exception
{
    public int Age { get; }
    public AgeOutOfRangeException(int age)
        : base($"年龄 {age} 不在合法范围")
    {
        Age = age;
    }
}

void CheckAge(int age)
{
    if (age < 0 || age > 150)
        throw new AgeOutOfRangeException(age);
}

try
{
    CheckAge(200);
}
catch (AgeOutOfRangeException ex)
{
    Console.WriteLine($"自定义异常:{ex.Message},年龄值={ex.Age}");
}
Note

年龄值 这类现场数据放进异常字段,调用方捕获后不用再去猜”到底哪个值错了”,排错更快。

Tip

C# 12 起可以用主构造器(primary constructor)把构造函数写得更短。上面的类也能写成:
class AgeOutOfRangeException(int Age) : Exception($"年龄 {Age} 不在合法范围");
主构造器参数 Age 直接成为字段,省去手写赋值。注意本章示例为兼顾可读性仍以显式写法为主。

提供标准构造函数

为了让你的异常能被框架和各种场景正确使用,建议提供三个构造函数:无参、带消息、带消息加内部异常。反序列化用的那个可以省略或从简。

namespace CSharpDemo;

class AgeOutOfRangeException : Exception
{
    public int Age { get; }

    public AgeOutOfRangeException()
    {
    }

    public AgeOutOfRangeException(string message) : base(message)
    {
    }

    public AgeOutOfRangeException(string message, Exception inner)
        : base(message, inner)
    {
    }

    public AgeOutOfRangeException(int age)
        : base($"年龄 {age} 不在合法范围")
    {
        Age = age;
    }
}
Tip

只加一个”带业务字段”的构造函数也够用。但补上那三个标准构造函数,将来若要做序列化或跨层传递会更省心。框架在反射创建异常实例、或反序列化还原时,可能调用无参/带参版本。

[Serializable] 是什么(简述)

你可能在老代码里见过 [Serializable] 标在异常上。它是给”异常需要被序列化”(比如跨进程、远程调用、写到日志文件再还原)用的。在纯内存、单进程的现代应用里,它通常可以省略;如果写的是可能被远程调用的库,可加上。

namespace CSharpDemo;

[Serializable]
class AgeOutOfRangeException : Exception
{
    public int Age { get; }
    public AgeOutOfRangeException(int age)
        : base($"年龄 {age} 不在合法范围") => Age = age;
}
Note

新手不用纠结 [Serializable]。先用最简单的形式把异常定义出来、能用起来,比一次写全所有细节更重要。

自定义异常也能携带方法

除了字段,你还可以给异常加只读属性或辅助方法,让”错误信息”更结构化。比如附带”允许的最小/最大值”,调用方就能在提示里直接展示范围。

namespace CSharpDemo;

class AgeOutOfRangeException : Exception
{
    public int Age { get; }
    public int Min { get; }
    public int Max { get; }

    public AgeOutOfRangeException(int age, int min, int max)
        : base($"年龄 {age} 不在 {min}~{max} 之间")
    {
        Age = age;
        Min = min;
        Max = max;
    }
}
Tip

携带结构化字段(而非只塞进一段消息文字),便于程序化地使用——日志系统、前端提示都能直接读 ex.Min / ex.Max,不必去解析消息字符串。

何时才值得自定义

自定义异常有成本(多一个类型、多套构造函数),不是越多越好。出现下面信号再考虑:

  1. 同一个错误会在多处抛出,且含义统一。
  2. 调用方需要按”你的错误类型”专门 catch 做不同处理。
  3. 想携带内置异常容纳不下的业务信息。

普通参数校验用 ArgumentNullException、ArgumentException 就够了,不必硬造新类型。

Warning

别为每个错误都发明一个新异常类。异常类型过多会让调用方疲于 catch,也稀释了每个类型的语义。先问自己:“调用方真的需要为它单独写 catch 吗?” 不需要就复用内置异常。

一个完整的端到端例子

把”定义—抛出—捕获—读字段”走通,体会自定义异常携带业务数据的价值:

namespace CSharpDemo;

class AgeOutOfRangeException : Exception
{
    public int Age { get; }
    public AgeOutOfRangeException(int age)
        : base($"年龄 {age} 不在 0~150 之间") => Age = age;
}

void CheckAge(int age)
{
    if (age < 0 || age > 150)
        throw new AgeOutOfRangeException(age);
}

try
{
    CheckAge(200);
}
catch (AgeOutOfRangeException ex)
{
    Console.WriteLine($"拒绝:{ex.Message},问题值={ex.Age}");
    // 这里能直接用 ex.Age 做进一步处理或上报
}

继承更具体的内置异常

自定义异常不必都直接继承 Exception。如果语义上它就是某种”参数错误”,可以继承 ArgumentException,这样既有了你的专属类型,又天然是 ArgumentException 的子类,调用方写 catch (ArgumentException) 也能接住。

namespace CSharpDemo;

class AgeOutOfRangeException : ArgumentException
{
    public int Age { get; }
    public AgeOutOfRangeException(int age)
        : base($"年龄 {age} 不在合法范围") => Age = age;
}
Note

继承哪个基类,取决于”我的异常本质属于哪一类”。属于参数问题就继承 ArgumentException,属于对象状态问题就继承 InvalidOperationException,这样异常类型体系才层次清晰。

异常消息怎么写

好的消息要”说清错 + 指条路”:不要只写”错误”,要写”什么错了、该怎么改”。例如 年龄 200 不在 0~150 之间 就比 参数非法 有用得多。

Tip

消息面向的是”将来读日志的人”。多写半句怎么修,能省下他半小时排查。

把携带字段设为只读

异常一旦抛出就不应被改。把 Age 这类业务字段设为只读({ get; } 且只在构造里赋值),避免被误改,也符合”异常是不可变快照”的直觉。

内置异常也能带根因

自定义异常同样可以接收 innerException,把底层异常包起来一起抛出,排错时顺着 InnerException 一路挖到根。

namespace CSharpDemo;

try
{
    try { int.Parse("x"); }
    catch (FormatException fe) { throw new AgeOutOfRangeException(200, fe); }
}
catch (AgeOutOfRangeException ex)
{
    Console.WriteLine($"外层:{ex.Message}");
    Console.WriteLine($"根因:{ex.InnerException?.Message}");
}

class AgeOutOfRangeException : Exception
{
    public AgeOutOfRangeException(int age, Exception? inner)
        : base($"年龄 {age} 非法", inner) { }
}
Note

若你的异常将来要跨进程传递或落盘还原,记得补上接受 SerializationInfoStreamingContext 的构造函数。普通应用用不到,知道有这回事即可。

与其它方案对比

方案适合场景
复用内置异常通用错误(空参、格式错),无需特别区分
自定义异常需按业务类型精确 catch、携带业务字段
返回错误码 / 结果对象高频、可预期的业务失败,不想用异常表达

一句话:异常类型稀少而精准,比”满地都是自定义异常”更可维护。

常见疑问解答

问:自定义异常一定要写满四个构造函数吗? 不一定。最小可用只需”带业务字段”那一个。若要序列化或框架反射创建实例,再补标准三件套。先用得起来更重要。

问:自定义异常应该用中文还是英文命名? 类名按 C# 惯例用 PascalCase 英文(如 InsufficientBalanceException)。消息文字可以用中文,面向中文用户更友好——消息是给人读的,类名是给编译器与代码读的。

问:能同时自定义多个异常表示”同一类错误的不同原因”吗? 能,且推荐:不同的原因用不同的子类,调用方就能分别 catch。前提是这些子类确实有”被分别处理”的必要,否则合并成一个带 Reason 字段的异常更省事。

最佳实践速记

  • 类名以 Exception 结尾,语义清晰。
  • 需要被精确 catch 或携带业务数据时再自定义,别滥用。
  • 业务字段设只读,保持异常不可变。
  • 标准构造函数尽量补全,便于框架与序列化。
  • 继承最贴切的内置基类,让类型体系有层次。
  • 消息写清”错在哪、怎么改”。

小结

自定义异常从 Exception 派生,类名以 Exception 收尾,带上业务字段并准备好标准构造函数;[Serializable] 仅在需要序列化时加上。别滥用——能复用内置异常就先用内置的。下一章我们转向资源管理:using 如何让对象自动”善后”。