1. 认识调用栈(Call Stack)
上节课我们简单提过调用栈,这次来详细聊聊它。
想象一下:用户在应用里点了个按钮。这个按钮会触发OnClick()(“按钮被按下”)方法,然后它又会调用LoadData()(加载数据),再接着是ReadFromFile()(从文件读取)。
突然在ReadFromFile()里出错了——文件没找到。谁的锅?
为了搞清楚,程序会“逆着足迹”往回走:从ReadFromFile()→LoadData()→OnClick()。这条路就是调用栈——就像一摞盘子,最上面的先掉下来。
程序会沿着这摞盘子往下走,直到遇到合适的catch,但沿途所有finally都会执行——这样资源才能被释放、文件被关掉、东西都收拾好。
在编程里,调用栈就是个列表,记着谁调用了谁,这样出错时就能顺着“请求链”回头查找原因。
它是怎么工作的
程序运行时,每次方法(或函数)被调用,都会往调用栈(列表)里加一行。如果运行过程中抛出异常,.NET的Exception类会保存这个栈的信息:哪些方法、按什么顺序被调用,最后导致了错误。
2. 调用栈到底有啥用
调用栈——调试(debug)复杂bug时你的好基友。
- 它不仅告诉你发生了啥,还会告诉你具体在哪、谁干的。
- 有时候,看着栈你会惊讶:程序怎么能走到这一步(尤其是有人不小心传了个奇怪的参数)。
常见场景:假设你有个巨大的项目,方法互相调用,嵌套十几层。突然冒出个NullReferenceException,你根本不知道程序怎么走到这的。打开栈——一整条调用链都能看到,立马就知道该从哪查起。
例子:
class MyClass
{
public void MethodA() { MethodB(); }
public void MethodB() { MethodC(); }
public void MethodC() {
throw new Exception("错误!");
}
public void Main()
{
try
{
MethodA();
}
catch (Exception ex)
{
Console.WriteLine(ex.StackTrace);
}
}
}
3. 怎么写自定义异常
有时候标准异常不够用
.NET里有一堆标准异常(ArgumentNullException、InvalidOperationException等等),但有时候还不够:
- 你的应用有自己的“游戏规则”:比如用户一次不能买超过10个商品,或者你的业务逻辑里金额不能为负数。
- 你想把应用逻辑的错误和“系统”错误区分开。
这时候就想自己造一个——反正可以嘛!
using System;
// 自定义异常:用户没找到
public class UserNotFoundException : Exception
{
// 基础构造函数
public UserNotFoundException() : base("用户没找到。") { }
// 带消息的构造函数
public UserNotFoundException(string message) : base(message) { }
// 带消息和内部异常的构造函数
public UserNotFoundException(string message, Exception inner) : base(message, inner) { }
}
关于构造函数的简要说明
- 无参数——用默认消息
- 自定义消息——有时候想加点细节
- 带内部异常(inner)——如果错误是“套娃”出来的,别丢了重要信息。
怎么用自定义异常
假设我们在任务管理应用里写个查找用户的方法:
using System;
public class UserService
{
public string FindUserNameById(int userId)
{
// “查找”用户,没找到就抛异常
if (userId != 42)
throw new UserNotFoundException($"id为{userId}的用户没找到。");
return "马西姆";
}
}
主程序里:
// 在Main里
var service = new UserService();
try
{
string name = service.FindUserNameById(17);
Console.WriteLine("用户名: " + name);
}
catch (UserNotFoundException ex)
{
Console.WriteLine("查找用户出问题: " + ex.Message);
// 这里也能通过ex.StackTrace拿到调用栈
}
如果我们传的id不是42,就会得到:
查找用户出问题: id为17的用户没找到。
4. 为什么要写自定义异常
日志和错误分组
比如你的应用里有各种各样的错误,要分开处理。数据库错误要记成“致命”,用户错误要弹红字,网络错误要重试。按异常类型分组就是个好办法。
面向对象和继承
可以把你的领域错误组织成继承体系:
public class MyAppException : Exception { ... }
public class OrderException : MyAppException { ... }
public class ProductException : MyAppException { ... }
public class TooManyItemsInOrderException : OrderException { ... }
现在,只要catch住MyAppException,你就能处理所有领域相关的错误;如果想对“订单太大”有特殊反应——就catch最具体的那个。
5. 写自定义异常要注意啥
- 别为了异常而异常
只有在这些情况下才写自定义异常:- 真的能让代码更清晰
- 调用方有可能catch它
- 想给调用方多点信息(通过字段/属性)
- 好习惯:序列化
.NET的标准异常都支持序列化(比如要跨网络传递)。简单应用用不到,但“进阶”场景下——加上[Serializable]属性并实现序列化构造函数(见官方文档)。做练习可以不管,工作上——问问你们组长 :)
序列化就是把对象变成方便存储或传输的格式(比如写到文件或发到网络)。我们以后会详细讲序列化。
6. 调用栈的细节:哪里容易搞混
调用栈只显示异常发生的路径。如果你catch了异常又抛了个新的,但没把“内部”异常传进去(就是没用Exception(string, Exception inner)构造函数),你可能会丢掉最初的错误信息。这叫“隐藏栈”。
不推荐:
try
{
// 这里出错了
}
catch (Exception ex)
{
throw new Exception("发生了未知错误。"); // 原来的栈信息没了!
}
推荐:
try
{
// 这里出错了
}
catch (Exception ex)
{
throw new Exception("发生了未知错误。", ex); // 保留原始栈信息!
}
这样StackTrace里既有最初的错误路径,也有你的新消息。
7. 实用建议和常见坑
- 别用异常来控制普通逻辑(比如“跳出循环”——有更优雅的写法!)。
- 只catch你需要的异常类型——总是catchException不太好(可能会“吞掉”你不该处理的错误)。
- 遇到复杂bug一定要记录调用栈。这能帮你省下无数调试时间。
- 用内部异常——这样不会丢掉最初的错误原因。
- 让你的自定义异常描述清楚——这样一年后别人(包括你自己)也能明白为啥会报这个错。
GO TO FULL VERSION