Dart 列挙型と拡張メソッド — 強化された列挙型
列挙型は有限状態に名前を与え、拡張は古い型に新しい能力を与える — どちらもソースを変更せずにコードを強化する強力なツールである。
1. 学べること
- 強化列挙型:プロパティ、コンストラクタ、メソッド
- switch との組み合わせ
- 拡張メソッド:定義と使用
- 拡張とプライバシー、命名競合の解決
- Bob のシナリオ:
OrderStatus列挙型 + String 拡張(USD フォーマット金額)
2. 開発者のリアルな物語
(1) 課題:文字列で状態をシミュレートするとタイポにつながる
Alice はコードで注文ステータスを表すのに文字列を使った:'pending'、'shipped'、'delivered'。タイポ 'shiped' はコンパイラにキャッチされず、注文が「未出荷」状態のままになり、200 件の顧客苦情につながった。また、if (status == 'pending' || status == 'processing') のようなチェックをよく書き、見落としがちだった。
(2) 列挙型による解決
Dart の強化列挙型は各状態に型安全な名前を与え、付属のプロパティとメソッドも提供する。switch 式は網羅性を保証し、状態の見落としはコンパイルエラーになる。
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',
};
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
(3) 効果
- タイポがランタイムではなくコンパイル時にキャッチされ、状態関連のバグが 90% 削減
- 網羅的な switch で見落としがなくなる
- 拡張メソッドにより、サブクラス化なしに String と num にビジネスメソッドを追加可能
3. 強化列挙型
(1) 基本列挙型
▶ サンプル:シンプルな列挙型
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
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
(2) 強化列挙型
▶ サンプル:プロパティ付き強化列挙型
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
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
▶ サンプル:メソッド付き強化列挙型
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()}');
}
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
4. switch と組み合わせた列挙型
▶ サンプル:網羅的な switch
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
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
| 機能 | if-else | switch 文 | switch 式 |
|---|---|---|---|
| 網羅性チェック | いいえ | いいえ | はい(列挙型の場合) |
| コンパイル時保証 | いいえ | いいえ | はい |
| 新しい列挙値の追加時 | 見落としの可能性 | 見落としの可能性 | コンパイルエラー |
5. 拡張メソッド
(1) 基本拡張
▶ サンプル:String 拡張
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
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
▶ サンプル:num 拡張(金額フォーマット)
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
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
▶ サンプル:List 拡張
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
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
6. 拡張、プライバシー、命名競合
(1) 命名競合の解決
▶ サンプル:名前空間による競合解決
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
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
| 競合シナリオ | 解決法 |
|---|---|
| 2 つの拡張が同じメソッド名を定義 | ExtensionName(obj).method() で明示的に呼び出す |
| 拡張メソッドとクラスメソッドが同じ名前 | クラスメソッドが優先、拡張メソッドは隠される |
| 2 つの拡張が異なるファイルにある | インポート順に依存する拡張の優先度 |
7. Bob のシナリオ:OrderStatus 列挙型 + String 拡張
▶ サンプル:DataPipeline の実践的列挙型と拡張
// ビジネスロジック付き注文ステータス列挙型
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');
}
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
8. 完全なサンプル:DataPipeline 注文状態マシン
// ============================================
// 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}');
}
> 出力: ローカルの DartPad または `dart run` で実行してください。この Dart コースの全例は Dart 3.x / Flutter 3.x ベースです。SDK バージョンにより結果が多少異なる場合があります。
出力:
=== 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: 強化列挙型はプロパティ、コンストラクタ、メソッドを持てます。通常の列挙型は
nameとindexのみを持ちます。Dart 2.17+ ではあらゆる場所で強化列挙型の使用が推奨されています。
Q: 列挙型はインターフェースを実装できますか? A: はい。列挙型はインターフェースを実装できます。例:
enum Status implements Comparable<Status>。ただし、他のクラスを継承することはできません(列挙型は暗黙的に Enum を継承します)。
Q: 拡張メソッドはプライベートメンバーにアクセスできますか? A: いいえ。拡張メソッドはクラスの外で定義され、公開メンバーのみにアクセスできます。これが拡張とクラスメソッドの根本的な違いです。
Q: 拡張メソッドは静的ディスパッチと動的ディスパッチのどちらですか? A: 静的ディスパッチです。コンパイラが変数の宣言型に基づいてコンパイル時に呼び出す拡張メソッドを決定します。実行時型は関係ありません。これがクラスメソッドとの本質的な違いで、クラスメソッドは動的ディスパッチされます。
Q: 拡張はプロパティを追加できますか? A: 計算プロパティ(ゲッター)は追加できますが、インスタンス変数(格納プロパティ)は追加できません。拡張はオブジェクトのメモリレイアウトを変更しません。
Q: 2 つの拡張が同じ名前のメソッドを定義した場合はどうなりますか? A: コンパイラがレシーバ型で区別できる場合は自動的に正しいものを選択します。区別できない場合(曖昧な場合)は、
ExtensionName(obj).method()を使って明示的に指定する必要があります。
Q: 列挙型の
valuesとbyNameでパフォーマンスの違いはありますか? A:valuesはキャッシュされたリストを返し、O(1) です。byNameはvaluesを反復してマッチを探すため、O(n) です。頻繁な検索には独自の Map キャッシュを作成することを検討してください。
📖 まとめ
- 強化列挙型は列挙値にプロパティ、コンストラクタ、メソッドを持たせることができ、単純な文字列定数より安全になる。
- switch 式と列挙型の組み合わせによりコンパイラが保証する網羅性が確保され;新しい列挙値を追加しても見落とされない。
- 拡張メソッドはソースコードを変更したりメモリレイアウトを変更したりせずに既存の型に機能を追加する。
- 拡張メソッドは静的ディスパッチされ、公開メンバーのみにアクセスできる。命名競合には明示的な解決が必要。
- DataPipeline は
OrderStatus列挙型を使って状態マシンを定義し、String/num 拡張を使って金額をフォーマットする。
📝 練習問題
- 基礎(難易度 ⭐):
json、csv、html値を含むOutputFormat強化列挙型を定義してください。各値にfileExtensionプロパティ(例:.json)とmimeTypeプロパティ(例:application/json)を持たせます。 - 中級(難易度 ⭐⭐):
Stringに拡張メソッドを追加してください:toOrderId(ORD-XXX としてフォーマット)、isValidEmail(メール形式検証)、truncateWithEllipsis(int max)(切り詰めて省略記号を追加)、それぞれをテストします。 - 挑戦(難易度 ⭐⭐⭐):強化列挙型を使って完全なワークフロー状態マシン(Draft → Review → Approved → Published)を実装してください。各状態に許可される遷移先を定義します。
transitionTo()メソッドを実装し、不正な遷移が拒否されることを検証してください。