10 异常处理

C 报告错误靠返回值与 errno:调用方必须逐个检查,忘记检查就让错误静默传播;setjmp/longjmp 想模拟异常又容易破坏资源状态。Dart 用结构化的 try/catch/finally 把「正常路径」与「错误路径」分开,异常携带类型、对象与完整栈追踪。本章讲清 Exception 与 Error 的分工、捕获语法、自定义异常、栈追踪,以及异常与返回码之间的取舍。


一、Exception 与 Error

Dart 的异常对象分两大家族,语义完全不同:

家族含义典型成员是否应该捕获
Exception可预期的业务/环境错误FormatExceptionIOExceptionTimeoutException应该捕获并处理
Error程序 bug,逻辑写错ArgumentErrorStateErrorTypeErrorRangeError通常不捕获,应该修复代码
void main() {
  // FormatException:输入格式错误,属于可恢复的异常
  try {
    print(int.parse('abc'));
  } on FormatException catch (e) {
    print('格式错误: ${e.message}');    // 格式错误: Invalid radix-10 number (at character 1)
  }
 
  // RangeError:下标越界,属于 Error,通常意味着代码有 bug
  try {
    print([1, 2, 3][10]);
  } on RangeError catch (e) {
    print('越界: ${e.message}');        // 越界: Invalid value
  }
}
维度C errnoDart ExceptionDart Error
携带信息全局整数 + 字符串类型 + 对象 + 栈类型 + 对象 + 栈
传递方式返回值手工检查自动沿调用栈上抛同左
可忽略性极易被忽略不捕获就终止(有日志)同左
语义所有错误一视同仁预期内的失败程序缺陷
处理建议立即检查按类型捕获恢复修复代码,不兜底

二、try / catch / on / finally

2.1 基本语法

void main() {
  // on 指定类型,只捕获匹配的异常
  try {
    final n = int.parse('42x');
    print(n);
  } on FormatException {
    print('捕获到格式异常');           // 捕获到格式异常
  } catch (e) {
    print('兜底捕获: $e');             // 其他类型异常走这里
  } finally {
    print('无论如何都执行');           // 无论如何都执行
  }
}

2.2 catch 的对象与栈

void main() {
  try {
    throw StateError('状态不对');
  } catch (e, st) {                   // e 是异常对象,st 是栈追踪
    print('类型: ${e.runtimeType}');  // 类型: StateError
    print('消息: $e');                // 消息: Bad state: 状态不对
    print('栈的第一帧: ${st.toString().split('\n')[1]}');
  }
}

2.3 多类型与组合

void main() {
  for (final input in ['12', 'abc', '0']) {
    try {
      final n = int.parse(input);
      print(100 ~/ n);
    } on FormatException catch (e) {
      print('$input 不是数字: ${e.message}');
    } on UnsupportedError {
      print('$input 不支持该运算');
    } on Object catch (e) {          // 等价于 catch (e),捕获一切
      print('其他: ${e.runtimeType}');
    } finally {
      print('--- $input 处理完毕');
    }
  }
}
子句作用匹配范围
on Type按类型捕获该类型及其子类
on Type catch (e)按类型捕获并拿对象该类型及其子类
catch (e, st)捕获一切,拿对象与栈所有异常
on Object catch (e)显式「捕获一切」所有异常(含 Error)
finally收尾清理,总是执行不参与匹配

三、rethrow:重新抛出

捕获后如果无法真正处理,应该用 rethrow 原样抛回,保留原始栈信息;用 throw e 会重置栈,丢失出错位置。

void loadConfig(String path) {
  try {
    throw FormatException('配置格式错误: $path');
  } on FormatException {
    print('记录日志: 配置读取失败');
    rethrow;                        // 栈信息保留,交给上层处理
  }
}
 
void main() {
  try {
    loadConfig('app.yaml');
  } on FormatException catch (e, st) {
    print('上层收到: ${e.message}');
    print('原始位置仍在栈中: ${st.toString().contains('loadConfig')}');   // true
  }
}
flowchart TD
    A["底层函数抛出异常"] --> B["中间层 try 捕获"]
    B --> C{"能完整处理吗"}
    C -->|能| D["处理并返回正常值"]
    C -->|不能| E["rethrow 原样上抛"]
    E --> F["上层 catch 收到原始栈"]
    B -.->|"错误做法 throw e"| G["栈被重置到当前行"]
    G --> H["出错位置丢失"]

四、自定义异常

业务错误应该定义专属异常类型,让上层能精确捕获,而不是靠解析字符串消息:

// 1. 简单实现:implements Exception,提供 toString
class InsufficientBalanceException implements Exception {
  final int required;
  final int available;
 
  InsufficientBalanceException(this.required, this.available);
 
  @override
  String toString() =>
      'InsufficientBalanceException: 需要 $required 元,仅有 $available 元';
}
 
class Account {
  int balance;
 
  Account(this.balance);
 
  void withdraw(int amount) {
    if (amount > balance) {
      throw InsufficientBalanceException(amount, balance);
    }
    balance -= amount;
  }
}
 
void main() {
  final account = Account(100);
  try {
    account.withdraw(250);
  } on InsufficientBalanceException catch (e) {
    print(e);                          // 完整信息
    print('缺口: ${e.required - e.available} 元');   // 缺口: 150 元
  }
}

也可以继承已有的 Exception 类(如 FormatException)复用消息字段。约定:自定义异常类名以 Exception 结尾,且实现 Exception 接口。


五、栈追踪 StackTrace

栈追踪是定位问题的第一手资料,Dart 会自动附加到未捕获异常的输出里:

void a() => b();
void b() => c();
void c() => throw Exception('出错了');
 
void main() {
  try {
    a();
  } catch (e, st) {
    print('异常: $e');
    // 打印完整栈,每行一个调用帧
    print(st);
    // 也可以格式化输出前几帧
    final frames = st.toString().trim().split('\n').take(3).join('\n');
    print('关键帧:\n$frames');
  }
}
维度CDart
出错位置__FILE__/__LINE__ 手工打印栈追踪自动携带
调用链靠 backtrace 库或调试器异常对象自带 StackTrace
日志记录手写catch (e, st) 一起落盘
符号化需编译选项开发期直接可读,AOT 产物可用 --obfuscate 混淆

六、与 C 的错误处理对比

/* C:返回值 + errno,调用方逐层检查,忘记检查就静默失败 */
int read_config(const char *path, Config *out) {
    FILE *f = fopen(path, "r");
    if (!f) return -1;                    /* 调用方必须检查 */
    if (parse(f, out) != 0) { fclose(f); return -2; }
    fclose(f);
    return 0;
}
 
/* C:setjmp/longjmp 模拟异常,但跳过清理逻辑,极易泄漏资源 */
jmp_buf env;
if (setjmp(env) == 0) {
    risky_operation();                    /* 内部 longjmp(env, 1) */
} else {
    /* 跳到这里,中间的资源谁释放? */
}
维度C 返回码C setjmp/longjmpDart 异常
错误传递手工逐层检查非局部跳转自动上抛
忘记处理静默继续未定义行为程序终止并打印栈
清理逻辑每个错误分支手写常被跳过,易泄漏finally 保证执行
类型信息errno 整数异常类型层次
携带数据全局 errno任意对象
性能几乎为零抛出时较贵,正常路径近乎零成本

结论:C 的返回码适合「高频、可预期」的错误(如 read 返回 -1);Dart 中高频路径也可以用返回码(如 int.parse 之外的 tryParse),真正的失败才用异常。


七、Zone 错误处理概览

Zone 是 Dart 的「错误处理域」:可以为一段代码指定统一的未捕获异常处理器,适合全局日志与兜底。异步代码中未被捕获的异常会交给所在 Zone 的处理器,而不是立刻崩溃。

import 'dart:async';
 
void main() {
  // 在自定义 Zone 中运行,统一拦截未捕获异常
  runZonedGuarded(
    () {
      // 这个异常没有任何 try/catch,会被 Zone 捕获
      Timer.run(() => throw StateError('异步中的未捕获异常'));
      print('主流程继续执行');
    },
    (error, stack) {
      print('Zone 捕获: $error');
      // 实际项目中在这里写日志、上报崩溃平台
    },
  );
}

Flutter 框架的 runAppFlutterError.onError 就是基于 Zone 的机制。异常类型与 Zone 的配合在 01 Flutter 入门 会再次出现。


八、异常 vs 返回错误:如何取舍

场景推荐方式理由
可预期的输入解析失败tryParse 返回 null高频、调用方本来就要校验
业务规则失败(余额不足)抛自定义异常正常路径不应携带错误分支
编程错误(参数非法)ArgumentError/StateError尽早暴露 bug
资源获取失败(文件、网络)抛异常调用栈深层,返回码难以逐层传递
批量操作中部分失败返回结果对象(如 (int, List<String>)部分成功需要继续
// 返回错误对象:适合「失败是常态」的场景
({bool ok, int? value, String? error}) parseIntSafe(String s) {
  final v = int.tryParse(s);
  return v != null ? (ok: true, value: v, error: null) : (ok: false, value: null, error: '不是数字');
}
 
void main() {
  final r = parseIntSafe('42');
  print(r.ok ? r.value : r.error);     // 42
}

常见坑

  1. catch (e) 捕获一切:连 Error(程序 bug)也吞掉,问题被掩盖;优先用 on 具体类型,兜底再考虑 catch
  2. 吞异常try { ... } catch (_) {} 什么都不做,排查时毫无线索;至少记录日志或 rethrow
  3. throw e 代替 rethrow:栈被重置到当前行,丢失原始出错位置
  4. finally 中写 return:会覆盖 try/catch 的返回值,甚至吞掉异常,分析器会给出 control_flow_in_finally 警告
  5. finally 中抛新异常:原始异常被替换,排查方向被带偏;finally 只做清理
  6. 异步异常没有 await 导致未捕获fetch(); 不 await,Future 里的异常无人处理,会触发 Zone 的未捕获处理
  7. Error 当业务异常处理RangeError 说明下标算错了,应该修代码而不是 try/catch 兜底
  8. 自定义异常不实现 Exception:Dart 不强制,但约定上应 implements Exception 并重写 toString

本章小结

  • Exception 是可预期的失败,应该捕获;Error 是程序缺陷,应该修复而不是兜底
  • try/on/catch/finally 结构化处理错误:on 按类型匹配、catch (e, st) 拿对象与栈、finally 保证清理
  • 无法处理时用 rethrow 保留原始栈;throw e 会重置栈,慎用
  • 自定义异常实现 Exception 接口,携带业务字段,让上层按类型精确捕获
  • StackTrace 自动携带调用链,日志中应连同异常对象一起记录
  • 对比 C:异常替代 errno 的逐层检查,finally 替代 goto cleanup 的清理逻辑
  • Zone 提供全局未捕获异常兜底,是 Flutter 框架错误处理的基础
  • 高频、可预期的失败用返回值(tryParse、结果 Record),真正的异常状况才 throw

练习

题号题目链接知识点
P5726打分https://www.luogu.com.cn/problem/P5726数组、去极值、异常边界

题目有 n 个评委打分,去掉一个最高分和一个最低分后求平均,保留 2 位小数。要点是用集合排序去极值,并练习对「空输入、元素不足」等边界做防御:这正是异常处理里「先判断前置条件」的思维。

import 'dart:io';
 
class EmptyScoreException implements Exception {
  @override
  String toString() => 'EmptyScoreException: 没有有效的打分';
}
 
double averageScore(List<int> scores) {
  if (scores.length < 3) {
    throw EmptyScoreException();          // 元素不足以去极值
  }
  final sorted = [...scores]..sort();     // 拷贝后排序,不改原数据
  final middle = sorted.sublist(1, sorted.length - 1);
  return middle.reduce((a, b) => a + b) / middle.length;
}
 
void main() {
  final lines = stdin.readAsLinesSync().where((l) => l.trim().isNotEmpty).toList();
  final n = int.parse(lines[0].trim());
  final scores = [
    for (var i = 1; i <= n; i++)
      ...lines[i].trim().split(RegExp(r'\s+')).map(int.parse),
  ];
 
  try {
    print(averageScore(scores).toStringAsFixed(2));
  } on EmptyScoreException catch (e) {
    print(e);
  }
}