Dart 例外処理 — 堅牢なエラー処理コードを書く
例外処理はコードの安全網である — それがなければ、たった 1 つのエラーがシステム全体をクラッシュさせる。
1. 学べること
- Exception と Error の意味的な違い
- try / on / catch / finally の完全な構文
- カスタム例外クラスとエラーコード設計
- rethrow と例外チェーン
- Bob のシナリオ:DataPipeline のファイルパース例外とネットワークタイムアウト処理
2. 開発者のリアルな物語
(1) 課題:未処理の例外がバッチ処理を途中でクラッシュさせる
Bob の DataPipeline は、数百万件の注文を処理している最中に、不正な形式の CSV 行が原因の FormatException でクラッシュした。処理済みの 80 万件すべてが失われ、完全な再実行が必要になった。さらに悪いことに、エラーメッセージは「FormatException」だけで行番号やコンテキストが示されず、Bob が問題を特定するのに 4 時間かかった。
(2) 例外処理による解決
try-on-catch-finally を使って特定の例外をキャッチし、カスタム例外クラスでコンテキスト情報を伝達し、finally でリソース解放を保証する。
try {
final records = await parseCsvFile(path);
await processRecords(records);
} on FormatException catch (e) {
log.error('CSV parse error: ${e.message} at line ${e.offset}');
// 不正なレコードをスキップして処理を続行
} on TimeoutException {
log.error('API timeout, retrying...');
await retryWithBackoff();
} finally {
await closeResources();
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
(3) 効果
- 1 つのレコードのエラーがバッチ全体の失敗を引き起こすことはなくなる
- カスタム例外が行番号やフィールドなどのコンテキストを伝達し、原因特定の時間が 4 時間から 5 分に短縮
finallyがファイルハンドルとネットワーク接続を常に解放することを保証
3. Exception vs Error
(1) 意味的な違い
flowchart TD A[Throwable] --> B[Error<br/>回復可能: いいえ] A --> C[Exception<br/>回復可能: はい] B --> B1[OutOfMemoryError] B --> B2[StackOverflowError] C --> C1[FormatException] C --> C2[TimeoutException] C --> C3[IOException] C --> C4[カスタム例外]
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
| 観点 | Error | Exception |
|---|---|---|
| 回復可能性 | 回復不可能 | 回復可能 |
| キャッチすべきか | いいえ | はい |
| 生成元 | VM / ランタイム | アプリケーションコード |
| 例 | StackOverflowError | FormatException |
4. try / on / catch / finally
(1) 完全な構文
▶ サンプル:基本的な try-catch
void main() {
try {
final result = int.parse('abc');
print(result);
} on FormatException catch (e) {
print('Format error: ${e.message}');
} catch (e) {
print('Unexpected error: $e');
}
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
▶ サンプル:on と catch の違い
void main() {
// on - 特定の型をキャッチ、例外オブジェクトにアクセスしない
try {
int.parse('not a number');
} on FormatException {
print('Caught FormatException (no details needed)');
}
// on + catch - 特定の型をキャッチし、例外にアクセス
try {
int.parse('not a number');
} on FormatException catch (e) {
print('Caught: ${e.message}');
}
// スタックトレース付き catch
try {
int.parse('not a number');
} on FormatException catch (e, stackTrace) {
print('Error: $e');
print('Stack: $stackTrace');
}
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
▶ サンプル:finally ブロック
import 'dart:io';
void main() async {
File? file;
try {
file = File('orders.csv');
final content = await file.readAsString();
print('Read ${content.length} characters');
} on FileSystemException catch (e) {
print('File error: ${e.message}');
} finally {
// 常に実行される - return や throw でも
print('Cleanup: file handle released');
}
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
| 句 | 目的 | 複数可能 | 順序 |
|---|---|---|---|
try |
例外をスローする可能性のあるコードを包む | 1 | 最初 |
on Type |
特定の型をキャッチ | 複数 | try の後 |
catch (e) |
任意の例外をキャッチ | 1 | on の後 |
finally |
常に実行される | 1 | 最後 |
5. カスタム例外クラス
(1) 例外クラス設計の原則
▶ サンプル:カスタム例外クラス
// DataPipeline のベース例外
class PipelineException implements Exception {
final String message;
final String? source;
final int? lineNumber;
PipelineException(this.message, {this.source, this.lineNumber});
@override
String toString() => 'PipelineException: $message'
'${source != null ? " (source: $source)" : ""}'
'${lineNumber != null ? " at line $lineNumber" : ""}';
}
// 特定の例外タイプ
class DataFormatException extends PipelineException {
final String fieldName;
final String invalidValue;
DataFormatException({
required this.fieldName,
required this.invalidValue,
required super.message,
super.source,
super.lineNumber,
});
@override
String toString() => 'DataFormatException: $message '
'(field: $fieldName, value: "$invalidValue")';
}
class NetworkTimeoutException extends PipelineException {
final Duration timeout;
final String endpoint;
NetworkTimeoutException({
required this.timeout,
required this.endpoint,
super.message = 'Request timed out',
});
@override
String toString() => 'NetworkTimeout: ${timeout.inSeconds}s '
'timeout on $endpoint';
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
▶ サンプル:エラーコード設計
enum ErrorCode {
fileNotFound('E001', 'File not found'),
invalidFormat('E002', 'Invalid data format'),
networkTimeout('E003', 'Network request timed out'),
authFailed('E004', 'Authentication failed'),
rateLimitExceeded('E005', 'Rate limit exceeded');
final String code;
final String description;
const ErrorCode(this.code, this.description);
}
class CodedException extends PipelineException {
final ErrorCode errorCode;
CodedException(this.errorCode, {String? detail})
: super('${errorCode.code}: ${errorCode.description}'
'${detail != null ? " - $detail" : ""}');
@override
String toString() => '[$errorCode] $message';
}
void main() {
try {
throw CodedException(ErrorCode.invalidFormat, detail: 'amount field is not a number');
} on CodedException catch (e) {
print(e); // [ErrorCode.invalidFormat] E002: Invalid data format - amount field is not a number
}
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
6. rethrow と例外チェーン
▶ サンプル:rethrow
double parseAmount(String input) {
try {
return double.parse(input);
} on FormatException catch (e) {
// ログして rethrow - 例外を飲み込まない
print('Failed to parse amount: "$input"');
rethrow; // 元のスタックトレースを保持
}
}
void main() {
try {
final amount = parseAmount('not_a_number');
} on FormatException {
print('Caught rethrown exception');
}
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
▶ サンプル:例外チェーン
class ChainedException implements Exception {
final String message;
final Exception? innerException;
ChainedException(this.message, {this.innerException});
@override
String toString() {
var result = 'ChainedException: $message';
if (innerException != null) {
result += '\n Caused by: $innerException';
}
return result;
}
}
Future<double> fetchOrderAmount(String orderId) async {
try {
// API 呼び出しをシミュレート
throw FormatException('Invalid JSON response');
} on FormatException catch (e) {
throw ChainedException(
'Failed to fetch order $orderId',
innerException: e,
);
}
}
void main() async {
try {
await fetchOrderAmount('ORD-001');
} on ChainedException catch (e) {
print(e);
}
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
7. Bob のシナリオ:DataPipeline の例外処理
▶ サンプル:完全な例外処理フロー
import 'dart:async';
// カスタム例外
class PipelineException implements Exception {
final String message;
PipelineException(this.message);
@override
String toString() => 'PipelineException: $message';
}
class CsvParseException extends PipelineException {
final int lineNumber;
CsvParseException(String message, this.lineNumber) : super(message);
@override
String toString() => 'CsvParseException: $message (line $lineNumber)';
}
// 例外処理付きの安全な CSV パーサー
List<Map<String, String>> parseCsv(String content) {
final lines = content.split('\n');
if (lines.isEmpty) throw PipelineException('Empty CSV content');
final headers = lines[0].split(',');
final records = <Map<String, String>>[];
for (var i = 1; i < lines.length; i++) {
final line = lines[i].trim();
if (line.isEmpty) continue;
try {
final values = line.split(',');
if (values.length != headers.length) {
throw CsvParseException(
'Column count mismatch: expected ${headers.length}, got ${values.length}',
i + 1,
);
}
final record = <String, String>{};
for (var j = 0; j < headers.length; j++) {
record[headers[j].trim()] = values[j].trim();
}
records.add(record);
} on CsvParseException {
rethrow;
} catch (e) {
throw CsvParseException('Unexpected error: $e', i + 1);
}
}
return records;
}
Future<void> processData(String csvContent) async {
List<Map<String, String>>? records;
try {
records = parseCsv(csvContent);
print('Parsed ${records.length} records');
// タイムアウト付きの API 呼び出しをシミュレート
await Future.delayed(const Duration(seconds: 1));
print('Data submitted successfully');
} on CsvParseException catch (e) {
print('Parse error: $e - skipping malformed records');
} on TimeoutException catch (e) {
print('Network timeout: $e - will retry later');
} on PipelineException catch (e) {
print('Pipeline error: $e');
} finally {
print('Cleanup: resources released');
}
}
void main() async {
final csv = 'id,amount,status\nORD-001,1500,completed\nORD-002,50,pending\nORD-003,bad_data,completed';
await processData(csv);
print('\n--- Test with malformed CSV ---');
final badCsv = 'id,amount\nORD-001,1500,completed'; // カラム数不一致
await processData(badCsv);
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
8. 完全なサンプル:DataPipeline の堅牢なデータ処理
// ============================================
// DataPipeline 堅牢なデータ処理
// カスタム例外による完全な例外処理
// ============================================
import 'dart:async';
// エラーコード
enum PipelineError {
fileNotFound('E001', 'File not found'),
invalidFormat('E002', 'Invalid data format'),
networkTimeout('E003', 'Network timeout'),
validationFailed('E004', 'Validation failed');
final String code;
final String label;
const PipelineError(this.code, this.label);
}
class PipelineException implements Exception {
final PipelineError error;
final String detail;
final Exception? cause;
PipelineException(this.error, {this.detail = '', this.cause});
@override
String toString() => '[${error.code}] ${error.label}'
'${detail.isNotEmpty ? ": $detail" : ""}'
'${cause != null ? " (caused by: $cause)" : ""}';
}
// 検証付き Order
class Order {
final String id;
final double amount;
final String status;
Order({required this.id, required this.amount, required this.status}) {
if (id.isEmpty) {
throw PipelineException(PipelineError.validationFailed, detail: 'Order ID is empty');
}
if (amount <= 0) {
throw PipelineException(PipelineError.validationFailed, detail: 'Amount must be positive: $amount');
}
}
@override
String toString() => 'Order($id, \$${amount.toStringAsFixed(2)}, $status)';
}
// 堅牢な注文パーサー
class OrderParser {
final List<PipelineException> _errors = [];
int parsed = 0;
int skipped = 0;
List<PipelineException> get errors => List.unmodifiable(_errors);
Order? tryParse(Map<String, dynamic> data) {
try {
final order = Order(
id: (data['id'] ?? '') as String,
amount: (data['amount'] as num).toDouble(),
status: (data['status'] ?? 'unknown') as String,
);
parsed++;
return order;
} on PipelineException catch (e) {
_errors.add(e);
skipped++;
return null;
} on TypeError catch (e) {
_errors.add(PipelineException(
PipelineError.invalidFormat,
detail: 'Type mismatch in record: $e',
));
skipped++;
return null;
}
}
void printReport() {
print('Parsed: $parsed, Skipped: $skipped');
if (_errors.isNotEmpty) {
print('Errors:');
for (final e in _errors) {
print(' $e');
}
}
}
}
void main() {
final rawData = <Map<String, dynamic>>[
{'id': 'ORD-001', 'amount': 1500.0, 'status': 'completed'},
{'id': '', 'amount': 500.0, 'status': 'pending'}, // 無効: ID が空
{'id': 'ORD-003', 'amount': -50.0, 'status': 'completed'}, // 無効: 負の金額
{'id': 'ORD-004', 'amount': 'not_a_number', 'status': 'pending'}, // 無効: 型違い
{'id': 'ORD-005', 'amount': 3200.0, 'status': 'completed'},
];
final parser = OrderParser();
final validOrders = <Order>[];
for (final data in rawData) {
final order = parser.tryParse(data);
if (order != null) validOrders.add(order);
}
print('=== DataPipeline Processing Report ===');
parser.printReport();
print('\nValid Orders:');
for (final order in validOrders) {
print(' $order');
}
final totalRevenue = validOrders.fold<double>(0, (s, o) => s + o.amount);
print('\nTotal Revenue: \$${totalRevenue.toStringAsFixed(2)} USD');
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
出力:
=== DataPipeline Processing Report ===
Parsed: 3, Skipped: 2
Errors:
[E004] Validation failed: Order ID is empty
[E004] Validation failed: Amount must be positive: -50.0
[E002] Invalid data format: Type mismatch in record: ...
Valid Orders:
Order(ORD-001, $1500.00, completed)
Order(ORD-005, $3200.00, completed)
Total Revenue: $4700.00 USD
❓ よくある質問
Q: Dart にはチェック例外がありますか? A: いいえ。Dart のすべての例外は非チェックで、コンパイラは宣言やキャッチを強制しません。これは柔軟性を提供しますが、開発者が意識的に例外を処理する必要があります。
Q:
onとcatchの違いは何ですか? A:on Typeは特定の例外型をキャッチし、変数をバインドしません(catchを追加しない限り)。catch (e)は任意の例外をキャッチして変数にバインドします。通常、組み合わせon Type catch (e)が使われます。
Q:
catchとrethrowの用途は何ですか? A:catchで例外をキャッチした後、ログ、クリーンアップなどを行うことができます。その後rethrowを使って同じ例外を再スローし、上位層が処理を継続できるようにします。rethrowは元のスタックトレースを保持するため、throw eより優れています。
Q:
finallyブロックはいつ実行されますか? A:finallyブロックは常に実行されます。tryブロック内で例外がスローされたかどうか、キャッチされたかどうか、return文があるかどうかに関わらず実行されます。唯一の例外はプログラムが強制終了された場合(例:SIGKILL)です。
Q: カスタム例外は何を継承すべきですか? A:
extend Exceptionではなくimplement Exceptionを推奨します。implementsの方が柔軟で、単一継承の制限を受けません。これは公式 Dart ガイドラインでも推奨されているアプローチです。
Q: 例外処理はパフォーマンスに影響しますか? A: try-catch ブロック自体にはほぼオーバーヘッドがありません(Dart VM は try ブロック内に追加の命令を入れません)。ただし、例外の作成とスローにはコストがかかるため、通常のフロー制御機構として使うべきではありません。
Q: 例外の飲み込みを避けるにはどうすればよいですか? A: 空の
catchブロックはコードの臭いです。少なくとも例外をログするか、rethrowしてください。無視しなければならない場合は、catch (_) {}を使い、なぜ無視するのか説明するコメントを追加してください。
📖 まとめ
- 例外は回復可能でキャッチすべき、Error は回復不可能でキャッチすべきでない
- 完全な try-on-catch-finally 構文:
onは型でキャッチ、catchは変数をバインド、finallyは常に実行 - カスタム例外は
implement Exceptionし、コンテキスト(行番号、フィールド、エラーコード)を伝達する rethrowは元のスタックトレースを保持する;例外チェーンは根本原因の追跡に役立つ- DataPipeline は
tryParseパターンを使用:1 つのレコードの失敗がバッチ処理全体に影響しない
📝 練習問題
- 基礎(難易度 ⭐):
int.parseを try-catch ブロック内で使うsafeParseInt(String s)関数を書いてください。パースに失敗した場合は例外をスローする代わりに0を返します。3 つのケースでテストしましょう。 - 中級(難易度 ⭐⭐):フィールド名、無効値、行番号を伝達するカスタム例外クラス
DataFormatExceptionを定義してください。形式エラー時にこの例外をスローする CSV パース関数を書き、呼び出し側でそれをキャッチして詳細情報を出力します。 - 挑戦(難易度 ⭐⭐⭐):カスタム再試行回数とバックオフ戦略をサポートするリトライ機構付きの HTTP リクエスト関数を実装してください。
TimeoutExceptionでリトライし、再試行回数を超えた後、個々の再試行例外を含む集約例外をスローします。