Dart 列挙型と拡張メソッド — 強化された列挙型

列挙型は有限状態に名前を与え、拡張は古い型に新しい能力を与える — どちらもソースを変更せずにコードを強化する強力なツールである。

1. 学べること


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

(1) 課題:文字列で状態をシミュレートするとタイポにつながる

Alice はコードで注文ステータスを表すのに文字列を使った:'pending''shipped''delivered'。タイポ 'shiped' はコンパイラにキャッチされず、注文が「未出荷」状態のままになり、200 件の顧客苦情につながった。また、if (status == 'pending' || status == 'processing') のようなチェックをよく書き、見落としがちだった。

(2) 列挙型による解決

Dart の強化列挙型は各状態に型安全な名前を与え、付属のプロパティとメソッドも提供する。switch 式は網羅性を保証し、状態の見落としはコンパイルエラーになる。

DART
enum OrderStatus {
  pending(label: 'Awaiting Processing', isFinal: false),
  shipped(label: 'In Transit', isFinal: false),
  delivered(label: 'Completed', isFinal: true),
  cancelled(label: 'Cancelled', isFinal: true);

  final String label;
  final bool isFinal;

  const OrderStatus({required this.label, required this.isFinal});
}

// 網羅的な switch - コンパイラが全ケースをチェック
String handle(OrderStatus status) => switch (status) {
  OrderStatus.pending => 'Queue for processing',
  OrderStatus.shipped => 'Track shipment',
  OrderStatus.delivered => 'Send survey',
  OrderStatus.cancelled => 'Process refund',
};
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

(3) 効果


3. 強化列挙型

(1) 基本列挙型

▶ サンプル:シンプルな列挙型

DART
enum OutputFormat {
  json,
  csv,
  html,
}

void main() {
  final format = OutputFormat.json;

  // 列挙値
  print(format.name);          // json
  print(format.index);         // 0
  print(OutputFormat.values);  // [OutputFormat.json, OutputFormat.csv, OutputFormat.html]

  // 文字列からパース
  final parsed = OutputFormat.values.byName('csv');
  print(parsed);  // OutputFormat.csv
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

(2) 強化列挙型

▶ サンプル:プロパティ付き強化列挙型

DART
enum OrderStatus {
  pending(label: 'Awaiting Processing', isFinal: false, priority: 1),
  processing(label: 'Being Processed', isFinal: false, priority: 2),
  shipped(label: 'In Transit', isFinal: false, priority: 3),
  delivered(label: 'Completed', isFinal: true, priority: 0),
  cancelled(label: 'Cancelled', isFinal: true, priority: 0);

  final String label;
  final bool isFinal;
  final int priority;

  const OrderStatus({required this.label, required this.isFinal, required this.priority});

  bool get isActive => !isFinal;

  String get displayName => '${name.toUpperCase()} - $label';
}

void main() {
  final status = OrderStatus.shipped;

  print(status.label);       // In Transit
  print(status.isFinal);     // false
  print(status.isActive);    // true
  print(status.displayName); // SHIPPED - In Transit
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

▶ サンプル:メソッド付き強化列挙型

DART
enum TaxCategory {
  standard(rate: 0.08, label: 'Standard Rate'),
  reduced(rate: 0.05, label: 'Reduced Rate'),
  zero(rate: 0.0, label: 'Zero Rate'),
  exempt(rate: 0.0, label: 'Tax Exempt');

  final double rate;
  final String label;

  const TaxCategory({required this.rate, required this.label});

  double calculate(double amount) => amount * rate;

  double applyTo(double amount) => amount * (1 + rate);

  String formatRate() => '${(rate * 100).toStringAsFixed(1)}%';
}

void main() {
  final tax = TaxCategory.standard;
  print(tax.calculate(1500.0));   // 120.0
  print(tax.applyTo(1500.0));     // 1620.0
  print(tax.formatRate());        // 8.0%

  // すべてのカテゴリ
  for (final cat in TaxCategory.values) {
    print('${cat.label}: ${cat.formatRate()}');
  }
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

4. switch と組み合わせた列挙型

▶ サンプル:網羅的な switch

DART
enum DataSourceType {
  api,
  file,
  database,
}

String describeSource(DataSourceType type) => switch (type) {
  DataSourceType.api => 'REST API endpoint',
  DataSourceType.file => 'Local file system',
  DataSourceType.database => 'SQL database connection',
};

// 網羅的チェック付き - コンパイラが全ケースを強制
bool canRetry(DataSourceType type) => switch (type) {
  DataSourceType.api => true,      // API は再試行可能
  DataSourceType.file => false,    // ファイルエラーは手動修正が必要
  DataSourceType.database => true, // DB はバックオフ付き再試行可能
};

void main() {
  print(describeSource(DataSourceType.api));  // REST API endpoint
  print(canRetry(DataSourceType.file));       // false
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
機能 if-else switch 文 switch 式
網羅性チェック いいえ いいえ はい(列挙型の場合)
コンパイル時保証 いいえ いいえ はい
新しい列挙値の追加時 見落としの可能性 見落としの可能性 コンパイルエラー

5. 拡張メソッド

(1) 基本拡張

▶ サンプル:String 拡張

DART
extension StringCurrency on String {
  String toUSD() => '\$$this USD';
  String toEUR() => '€${this} EUR';

  String truncate(int maxLength) =>
      length <= maxLength ? this : '${substring(0, maxLength)}...';

  String get capitalized =>
      isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
}

void main() {
  print('1500.00'.toUSD());         // $1500.00 USD
  print('1200.00'.toEUR());        // €1200.00 EUR
  print('Very long product name'.truncate(10));  // Very long...
  print('electronics'.capitalized); // Electronics
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

▶ サンプル:num 拡張(金額フォーマット)

DART
extension NumFormatting on num {
  String toUSD() => '\$${toStringAsFixed(2)} USD';
  String toCompact() {
    if (this >= 1000000) return '\$${(this / 1000000).toStringAsFixed(1)}M USD';
    if (this >= 1000) return '\$${(this / 1000).toStringAsFixed(1)}K USD';
    return toUSD();
  }

  double get asK => this / 1000;
  double get asM => this / 1000000;

  bool isBetween(num from, num to) => from <= this && this <= to;
}

void main() {
  print(1500.0.toUSD());       // $1500.00 USD
  print(1500000.0.toCompact()); // $1.5M USD
  print(5000.asK);             // 5.0
  print(1500.0.isBetween(1000, 2000)); // true
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

▶ サンプル:List 拡張

DART
extension ListStats on List<double> {
  double get sum => fold(0, (a, b) => a + b);
  double get average => isEmpty ? 0 : sum / length;
  double get median {
    final sorted = [...this]..sort();
    final mid = length ~/ 2;
    return length.isEven
        ? (sorted[mid - 1] + sorted[mid]) / 2
        : sorted[mid];
  }
}

void main() {
  final amounts = [1500.0, 3200.0, 890.0, 50.0];
  print('Sum: ${amounts.sum.toUSD()}');       // Sum: $5640.00 USD
  print('Average: ${amounts.average.toUSD()}'); // Average: $1410.00 USD
  print('Median: ${amounts.median.toUSD()}');  // Median: $1195.00 USD
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

6. 拡張、プライバシー、命名競合

(1) 命名競合の解決

▶ サンプル:名前空間による競合解決

DART
extension MathExtras on num {
  int get squared => (this * this).toInt();
}

extension StringExtras on String {
  String get reversed => split('').reversed.join('');
}

// 2 つの拡張が同じメソッド名を持つ場合
extension DoubleExtras on double {
  String toMoney() => '\$${toStringAsFixed(2)}';
}

extension IntExtras on int {
  String toMoney() => '\$${this}.00';
}

void main() {
  // 直接呼び出し - コンパイラが型で解決
  print(5.squared);            // 25
  print('hello'.reversed);     // olleh

  // 曖昧な場合は明示的解決
  print(DoubleExtras(1500.5).toMoney());  // $1500.50
  print(IntExtras(1500).toMoney());       // $1500.00
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
競合シナリオ 解決法
2 つの拡張が同じメソッド名を定義 ExtensionName(obj).method() で明示的に呼び出す
拡張メソッドとクラスメソッドが同じ名前 クラスメソッドが優先、拡張メソッドは隠される
2 つの拡張が異なるファイルにある インポート順に依存する拡張の優先度

7. Bob のシナリオ:OrderStatus 列挙型 + String 拡張

▶ サンプル:DataPipeline の実践的列挙型と拡張

DART
// ビジネスロジック付き注文ステータス列挙型
enum OrderStatus {
  pending(label: 'Awaiting Processing', isFinal: false),
  processing(label: 'Being Processed', isFinal: false),
  shipped(label: 'In Transit', isFinal: false),
  delivered(label: 'Completed', isFinal: true),
  cancelled(label: 'Cancelled', isFinal: true),
  refunded(label: 'Refunded', isFinal: true);

  final String label;
  final bool isFinal;

  const OrderStatus({required this.label, required this.isFinal});

  bool get isActive => !isFinal;
  bool get canCancel => this == pending || this == processing;
  bool get canRefund => this == delivered;
}

// DataPipeline フォーマット用 String 拡張
extension DataPipelineString on String {
  String get asOrderId => 'ORD-$this';
  String toUSD() => '\$$this USD';
  String get toCategoryLabel => split('_').map((w) => w.capitalizeFirst).join(' ');
}

extension StringCap on String {
  String get capitalizeFirst =>
      isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}';
}

// 売上フォーマット用 num 拡張
extension RevenueFormatting on num {
  String toRevenue() => '\$${toStringAsFixed(2)} USD';
  String toCompactRevenue() {
    if (this >= 1000000) return '\$${(this / 1000000).toStringAsFixed(1)}M USD';
    if (this >= 1000) return '\$${(this / 1000).toStringAsFixed(1)}K USD';
    return toRevenue();
  }
}

void main() {
  // 列挙型の使用
  final status = OrderStatus.shipped;
  print('Status: ${status.label}');      // In Transit
  print('Active: ${status.isActive}');   // true
  print('Can cancel: ${status.canCancel}'); // false

  // String 拡張
  print('001'.asOrderId);               // ORD-001
  print('1500.00'.toUSD());             // $1500.00 USD

  // 売上フォーマット
  print(1500000.toCompactRevenue());     // $1.5M USD
  print(52500.75.toRevenue());           // $52500.75 USD

  // 列挙型の網羅的 switch
  for (final s in OrderStatus.values) {
    final action = switch (s) {
      OrderStatus.pending => 'Queue for processing',
      OrderStatus.processing => 'Monitor progress',
      OrderStatus.shipped => 'Track delivery',
      OrderStatus.delivered => 'Send confirmation',
      OrderStatus.cancelled => 'Process cancellation',
      OrderStatus.refunded => 'Update records',
    };
    print('  ${s.name}: $action');
  }
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

8. 完全なサンプル:DataPipeline 注文状態マシン

DART
// ============================================
// DataPipeline 注文状態マシン
// 強化列挙型 + 拡張の活用
// ============================================

enum OrderStatus {
  pending(label: 'Awaiting Processing', isFinal: false, color: 'yellow'),
  processing(label: 'Being Processed', isFinal: false, color: 'blue'),
  shipped(label: 'In Transit', isFinal: false, color: 'orange'),
  delivered(label: 'Completed', isFinal: true, color: 'green'),
  cancelled(label: 'Cancelled', isFinal: true, color: 'red'),
  refunded(label: 'Refunded', isFinal: true, color: 'gray');

  final String label;
  final bool isFinal;
  final String color;

  const OrderStatus({
    required this.label,
    required this.isFinal,
    required this.color,
  });

  bool get isActive => !isFinal;
  bool get canTransition => !isFinal;

  List<OrderStatus> get allowedTransitions => switch (this) {
    pending => [processing, cancelled],
    processing => [shipped, cancelled],
    shipped => [delivered],
    delivered => [refunded],
    cancelled => [],
    refunded => [],
  };

  bool canTransitionTo(OrderStatus target) =>
      allowedTransitions.contains(target);
}

extension NumRevenue on num {
  String toUSD() => '\$${toStringAsFixed(2)} USD';
}

class Order {
  final String id;
  final double amount;
  OrderStatus status;

  Order({required this.id, required this.amount, this.status = OrderStatus.pending});

  bool transitionTo(OrderStatus newStatus) {
    if (!status.canTransitionTo(newStatus)) {
      print('  Cannot transition from ${status.name} to ${newStatus.name}');
      return false;
    }
    print('  $id: ${status.name} → ${newStatus.name}');
    status = newStatus;
    return true;
  }

  String get summary => '$id: ${status.label} (${amount.toUSD()})';
}

void main() {
  final order = Order(id: 'ORD-001', amount: 1500.0);

  print('=== Order State Machine ===');
  print('Initial: ${order.summary}');

  // 有効な遷移
  order.transitionTo(OrderStatus.processing);  // OK
  order.transitionTo(OrderStatus.shipped);      // OK
  order.transitionTo(OrderStatus.delivered);    // OK

  // 無効な遷移
  order.transitionTo(OrderStatus.cancelled);  // 不可:delivered → cancelled

  // 有効な返金
  order.transitionTo(OrderStatus.refunded);   // OK

  print('\nFinal: ${order.summary}');

  // 全状態と遷移を表示
  print('\n=== State Transition Table ===');
  for (final status in OrderStatus.values) {
    final targets = status.allowedTransitions.map((t) => t.name).join(', ');
    print('  ${status.name.padRight(12)} → ${targets.isEmpty ? '(final)' : targets}');
  }

  // ステータス統計
  print('\n=== Status Properties ===');
  final activeCount = OrderStatus.values.where((s) => s.isActive).length;
  final finalCount = OrderStatus.values.where((s) => s.isFinal).length;
  print('Active states: $activeCount');
  print('Final states:  $finalCount');
  print('Total states:  ${OrderStatus.values.length}');
}
TEXT
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。

出力:

TEXT
=== Order State Machine ===
Initial: ORD-001: Awaiting Processing ($1500.00 USD)
  ORD-001: pending → processing
  ORD-001: processing → shipped
  ORD-001: shipped → delivered
  Cannot transition from delivered to cancelled
  ORD-001: delivered → refunded

Final: ORD-001: Refunded ($1500.00 USD)

=== State Transition Table ===
  pending      → processing, cancelled
  processing   → shipped, cancelled
  shipped      → delivered
  delivered    → refunded
  cancelled    → (final)
  refunded     → (final)

=== Status Properties ===
Active states: 3
Final states:  3
Total states:  6

❓ よくある質問

Q: 強化列挙型と通常の列挙型の違いは何ですか? A: 強化列挙型はプロパティ、コンストラクタ、メソッドを持てます。通常の列挙型は nameindex のみを持ちます。Dart 2.17+ ではあらゆる場所で強化列挙型の使用が推奨されています。

Q: 列挙型はインターフェースを実装できますか? A: はい。列挙型はインターフェースを実装できます。例:enum Status implements Comparable<Status>。ただし、他のクラスを継承することはできません(列挙型は暗黙的に Enum を継承します)。

Q: 拡張メソッドはプライベートメンバーにアクセスできますか? A: いいえ。拡張メソッドはクラスの外で定義され、公開メンバーのみにアクセスできます。これが拡張とクラスメソッドの根本的な違いです。

Q: 拡張メソッドは静的ディスパッチと動的ディスパッチのどちらですか? A: 静的ディスパッチです。コンパイラが変数の宣言型に基づいてコンパイル時に呼び出す拡張メソッドを決定します。実行時型は関係ありません。これがクラスメソッドとの本質的な違いで、クラスメソッドは動的ディスパッチされます。

Q: 拡張はプロパティを追加できますか? A: 計算プロパティ(ゲッター)は追加できますが、インスタンス変数(格納プロパティ)は追加できません。拡張はオブジェクトのメモリレイアウトを変更しません。

Q: 2 つの拡張が同じ名前のメソッドを定義した場合はどうなりますか? A: コンパイラがレシーバ型で区別できる場合は自動的に正しいものを選択します。区別できない場合(曖昧な場合)は、ExtensionName(obj).method() を使って明示的に指定する必要があります。

Q: 列挙型の valuesbyName でパフォーマンスの違いはありますか? A: values はキャッシュされたリストを返し、O(1) です。byNamevalues を反復してマッチを探すため、O(n) です。頻繁な検索には独自の Map キャッシュを作成することを検討してください。


📖 まとめ


📝 練習問題

  1. 基礎(難易度 ⭐)jsoncsvhtml 値を含む OutputFormat 強化列挙型を定義してください。各値に fileExtension プロパティ(例:.json)と mimeType プロパティ(例:application/json)を持たせます。
  2. 中級(難易度 ⭐⭐)String に拡張メソッドを追加してください:toOrderId(ORD-XXX としてフォーマット)、isValidEmail(メール形式検証)、truncateWithEllipsis(int max)(切り詰めて省略記号を追加)、それぞれをテストします。
  3. 挑戦(難易度 ⭐⭐⭐):強化列挙型を使って完全なワークフロー状態マシン(Draft → Review → Approved → Published)を実装してください。各状態に許可される遷移先を定義します。transitionTo() メソッドを実装し、不正な遷移が拒否されることを検証してください。

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

Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%