Dart 例外処理 — 堅牢なエラー処理コードを書く

例外処理はコードの安全網である — それがなければ、たった 1 つのエラーがシステム全体をクラッシュさせる。

1. 学べること


2. 開発者のリアルな物語

(1) 課題:未処理の例外がバッチ処理を途中でクラッシュさせる

Bob の DataPipeline は、数百万件の注文を処理している最中に、不正な形式の CSV 行が原因の FormatException でクラッシュした。処理済みの 80 万件すべてが失われ、完全な再実行が必要になった。さらに悪いことに、エラーメッセージは「FormatException」だけで行番号やコンテキストが示されず、Bob が問題を特定するのに 4 時間かかった。

(2) 例外処理による解決

try-on-catch-finally を使って特定の例外をキャッチし、カスタム例外クラスでコンテキスト情報を伝達し、finally でリソース解放を保証する。

DART
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();
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

(3) 効果


3. Exception vs Error

(1) 意味的な違い

100%
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[カスタム例外]
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
観点 Error Exception
回復可能性 回復不可能 回復可能
キャッチすべきか いいえ はい
生成元 VM / ランタイム アプリケーションコード
StackOverflowError FormatException
⚠️ 注意: Error はキャッチしないでください。Error はプログラム状態が破損していることを示しており、それをキャッチして実行を続けるとより深刻な問題につながる可能性があります。Exception のみをキャッチしてください。


4. try / on / catch / finally

(1) 完全な構文

▶ サンプル:基本的な try-catch

DART
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');
  }
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

▶ サンプル:on と catch の違い

DART
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');
  }
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

▶ サンプル:finally ブロック

DART
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');
  }
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
目的 複数可能 順序
try 例外をスローする可能性のあるコードを包む 1 最初
on Type 特定の型をキャッチ 複数 try の後
catch (e) 任意の例外をキャッチ 1 on の後
finally 常に実行される 1 最後

5. カスタム例外クラス

(1) 例外クラス設計の原則

▶ サンプル:カスタム例外クラス

DART
// 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';
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

▶ サンプル:エラーコード設計

DART
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
  }
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

6. rethrow と例外チェーン

▶ サンプル:rethrow

DART
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');
  }
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

▶ サンプル:例外チェーン

DART
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);
  }
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

7. Bob のシナリオ:DataPipeline の例外処理

▶ サンプル:完全な例外処理フロー

DART
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);
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

8. 完全なサンプル:DataPipeline の堅牢なデータ処理

DART
// ============================================
// 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');
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

出力:

TEXT
=== 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: oncatch の違いは何ですか? A: on Type は特定の例外型をキャッチし、変数をバインドしません(catch を追加しない限り)。catch (e) は任意の例外をキャッチして変数にバインドします。通常、組み合わせ on Type catch (e) が使われます。

Q: catchrethrow の用途は何ですか? 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 (_) {} を使い、なぜ無視するのか説明するコメントを追加してください。


📖 まとめ


📝 練習問題

  1. 基礎(難易度 ⭐)int.parse を try-catch ブロック内で使う safeParseInt(String s) 関数を書いてください。パースに失敗した場合は例外をスローする代わりに 0 を返します。3 つのケースでテストしましょう。
  2. 中級(難易度 ⭐⭐):フィールド名、無効値、行番号を伝達するカスタム例外クラス DataFormatException を定義してください。形式エラー時にこの例外をスローする CSV パース関数を書き、呼び出し側でそれをキャッチして詳細情報を出力します。
  3. 挑戦(難易度 ⭐⭐⭐):カスタム再試行回数とバックオフ戦略をサポートするリトライ機構付きの HTTP リクエスト関数を実装してください。TimeoutException でリトライし、再試行回数を超えた後、個々の再試行例外を含む集約例外をスローします。

← 前のレッスン | 次のレッスン →

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%