【実務・中級編】Dart 3のsealed classと変数宣言を組み合わせた、網羅的な状態管理の実装 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

序論:なぜ、あなたの書く状態管理コードは「バグ」を生み続けるのか?

コードレビューをしていて、次のようなコードに遭遇したことはないだろうか。

// よくある冗長で脆弱な状態判定
class UIState {
final bool isLoading;
final Data? data;
final String? error;

const UIState({this.isLoading = false, this.data, this.error});
}

// 使う側
Widget build(BuildContext context) {
if (state.isLoading) {
return CircularProgressIndicator();
} else if (state.error != null) {
return Text(state.error!);
} else if (state.data != null) {
return DataView(data: state.data!);
}
return const SizedBox.shrink(); // 「ありえないはず」のフォールバック
}

このコードの何が問題か。「起き得ないはずの状態(例:`isLoading`が真かつ`error`が存在する)」を型のレベルで防げておらず、さらに新しい状態を追加した際に条件分岐の網羅性(Exhaustiveness)がコンパイル時に保証されない点だ。

Webフロントエンドや非同期API連携の現場において、UIの状態管理はアプリケーションの信頼性を左右する心臓部である。ここで妥協した設計をすると、ランダムなタイミングで発生するUIのフリーズや、未知のエラーによるクラッシュの温床となる。

Dart 3で導入された `sealed class` は、この構造的な欠陥をコンパイラの力で完全に根絶するための最強の武器だ。今回は、Dartコアの仕組みとコンパイル時の振る舞いまで踏み込み、実務で即座に使える堅牢な状態管理の極意を伝授しよう。

—

1. Dart 3 `sealed class` の正体とコンパイル時最適化

まず、言語仕様の深淵を覗こう。`sealed` 修飾子は、単なる「継承制限」ではない。

Dartのコンパイラ(CFA: Control Flow Analysis および 2.12以降のサウンドなNull安全システム)は、`sealed class` が同一ライブラリ内でのみサブクラス化可能であるという制約を利用して、「取りうる型のバリエーション(Union Type的アプローチ)」を完全に把握する。

これにより、`switch` 式(Dart 3で導入されたパターンマッチング)と組み合わせた際、コンパイラは次のような静的解析を行える。

1. 網羅性チェック(Exhaustiveness Checking): すべてのサブクラスが処理されているか。
2. スマートキャストの極限活用: マッチした分岐内では、追加のキャスト(`as`)なしでサブクラス固有のフィールドに直接アクセスできる。

ランタイムにおいて、`sealed class` は通常のクラス階層と変わらないオーバーヘッドの少ないジャンプテーブルまたは型チェックとして処理されるが、開発時・コンパイル時においては、バグの温床となる「網羅漏れ」を物理的にコンパイルエラーとして検知する。これこそが、アーキテクトが `sealed class` を推す理由である。

—

2. 実践:API連携を伴う堅牢な非同期状態管理の構築

では、実務のプロダクションコードでどのように実装すべきか。
「非同期API通信(取得中、成功、失敗)」を題材に、`var`, `final`, `const` を適切に使い分け、パフォーマンスと安全性を両立させたコードを示す。

以下のコードは、そのままプロジェクトにコピー&ペーストして、コンポーネント設計やBLoC/Riverpodの状態定義のベースとして利用できる。

import ‘dart:async’;

/// 1. sealed classによる状態の定義
/// 同一ファイル(または同一ライブラリ)内でのみサブクラス化を許可。
sealed class ApiState {
const ApiState();
}

/// 待機・初期状態
final class ApiInitial extends ApiState {
const ApiInitial();
}

/// ローディング中
final class ApiLoading extends ApiState {
const ApiLoading();
}

/// 成功(不変データを保持)
final class ApiSuccess extends ApiState {
final T data;
const ApiSuccess(this.data);
}

/// 失敗(エラー情報と、リトライ用の前段データを保持することもある)
final class ApiFailure extends ApiState {
final Object error;
final StackTrace stackTrace;
const ApiFailure(this.error, this.stackTrace);
}

/// 2. 状態をハンドリングするビジネスロジック / ViewModel層
class UserViewModel {
// 状態の保持には immutable な final 変数を使用
// 内部的な書き換えには private な ValueNotifier や StreamController を想定
ApiState _state = const ApiInitial();
ApiState get state => _state;

Future fetchUserData() async {
_state = const ApiLoading();
// 状態変更通知のロジックがここに入る (notifyListeners等)

try {
// ネットワーク遅延のシミュレーション
await Future.delayed(const Duration(seconds: 2));

// 成功レスポンス
const fetchedData = “Dart 3 Architecture Masterclass”;
_state = const ApiSuccess(fetchedData);

} catch (e, st) {
// 予期せぬ例外のキャッチ
_state = ApiFailure(e, st);
} finally {
// 状態変更通知
}
}

/// 3. パターンマッチングによる網羅的な状態レンダリング(UI層の例)
String renderUI() {
// Dart 3の switch「式」を活用。
// ここで ApiFailure などの処理を書き忘れると、
// 「The type ‘ApiState‘ is not exhaustively matched…」
// というコンパイルエラーが即座に発生する。
return switch (_state) {
ApiInitial() => ‘画面を初期化しています…’,
ApiLoading() => ‘読み込み中…’,
ApiSuccess(data: final d) => ‘データ取得成功: $d’,
ApiFailure(error: final err, stackTrace: _) => ‘エラーが発生しました: $err’,
};
}
}

/// 4. 実行確認用エントリーポイント
void main() async {
final viewModel = UserViewModel();

// 初期状態の確認
print(viewModel.renderUI());

// 非同期フェッチの実行と結果のトレース
final future = viewModel.fetchUserData();

// ローディング中の状態
print(viewModel.renderUI());

await future;

// 成功後の状態
print(viewModel.renderUI());
}

—

3. コードレビューの視点:なぜこの実装が優れているのか?

上記のコードには、Dartの挙動を熟知したアーキテクトならではのこだわりが詰まっている。レビューアーの視点で、その理由を言語化する。

① `final class` の多用によるメモリ効率と最適化

サブクラスである `ApiInitial`, `ApiLoading`, `ApiSuccess`, `ApiFailure` にはすべて `final class` を指定している。
これにより、Dart VMはこれらのクラスがこれ以上継承されない(=クラス階層の末端である)ことを確信できるため、仮想メソッドテーブル(vtable)の最適化や、インラインキャッシュの効率化といったコンパイラレベルの恩恵を受けやすくなる。

② `const` コンストラクタによるゼロアロケーション

すべての状態クラスに `const` コンストラクタを付与している。
特に `ApiInitial` や `ApiLoading` のようなペイロード(保持データ)を持たない状態は、アプリケーション全体で同一のインスタンスを定数として再利用(Canonicalization)できるため、ガベージコレクション(GC)の負荷を実質ゼロに抑えられる。無駄なオブジェクト生成を排することは、フロントエンドのフレームレート維持(Jankの防止)において極めて重要だ。

③ switch「式(Expression)」によるバグの予防

従来の `switch-case` 文は文(Statement)であり、breakの書き忘れや網羅漏れがランタイムエラー(または無視)を引き起こしていた。
一方、Dart 3の `switch` 式 は値を返すため、すべてのパターンが網羅されていない場合、コンパイルが通らない。将来的に仕様変更で `ApiTokenExpired` などの新しい状態を追加した瞬間、その状態を処理し忘れているすべての箇所でコンパイルエラーが発生し、修正漏れを100%防ぐことができる。

—

4. パフォーマンス上の注意点と実務のアンチパターン

最後に、現場でやりがちな「やってはいけないアンチパターン」に言及しておこう。

  • アンチパターン1: `sealed class` の肥大化

1つの状態クラスに、あらゆる画面の情報を詰め込むのは避けるべき。状態の粒度が大きすぎると、不必要なUIの再描画(Rebuild)が頻発する。状態は「画面単位」、あるいは「コンポーネント単位」で適切なスコープに分割すること。

  • アンチパターン2: 網羅的マッチングでの `default` や `_` の乱用

`switch` 式の最後に `_ =>`(ワイルドカード)を安易に置くと、新しいサブクラスを追加した際にコンパイラがエラーを教えてくれなくなる。ワイルドカードは、どうしても予測不能な外部入力を扱う場合を除き、状態管理の網羅性チェックにおいては「封印」すべきである。

—

結び:型を味方につけた者だけが、保守性の高いコードを書ける

変数宣言(`var`, `final`, `const`)の厳格な使い分けと、`sealed class` による状態の静的保証。これらを組み合わせることで、あなたの書くコードから「原因不明のUIバグ」は駆逐される。

言語の仕様やコンパイラの挙動を深く理解し、機械に任せられるチェックはすべて機械(コンパイラ)に任せる。それこそが、モダンなDartエンジニアが到達すべき「極限の知見」である。さあ、今すぐ既存の脆弱な状態管理コードを `sealed class` でリファクタリングしに行こう。

タイトルとURLをコピーしました