【実務・中級編】Dartのグローバル変数とstatic定数の管理:メモリリークを防ぐための依存関係設計 – Dart コア文法・オブジェクト指向・Null安全解析バイブル

コードレビュー開始:そのグローバル変数、本当に必要か?

開発チームの皆さん、お疲れ様です。テクニカルリードの私だ。

今日のコードレビューで、いくつか気になる実装を見かけた。アプリケーション全体で共有したいという意図から、ファイルの一番上に `var` や非 `const` なオブジェクトを平然と配置し、あちこちのコンポーネントやAPIクライアントから直接読み書きしているコードだ。

「手軽にアクセスできて便利だから」
「状態をグローバルに持たせれば、ウィジェットツリーのバケツリレー(プロパティのたらい回し)を防げるから」

……その気持ちは分からないでもない。だが、フロントエンド開発、特にFlutterを用いた高度なUI構築や、複雑な非同期API連携を行うモダンなWeb・アプリ開発において、無計画なグローバル変数の乱用は、メモリリークと予期せぬバグの温床になる。

今回は、Dartの変数宣言(`var`, `final`, `const`)の言語仕様レベルでの挙動の違いを紐解きつつ、Dart VMやIsolateのメモリモデルを踏まえた「安全かつ堅牢なグローバル状態・定数設計」について徹底的に解説する。

—

1. なぜグローバル変数は「悪」なのか? Dart VMのメモリモデルから知る真実

まず、Dartという言語のランタイム特性を思い出してほしい。Dartはガベージコレクション(GC)を持つ言語だが、「どこからも参照されなくなったオブジェクト」しか回収されない。

ファイルスコープ(トップレベル)に宣言された変数や、クラスの `static` 変数は、プログラムが生存している限り、ルート参照(Root Set)としてヒープ領域に居座り続ける。

危険なパターン:トップレベルの `var` や可変オブジェクト

// ❌ 絶対にやってはいけないアンチパターン
// このリストはアプリが起動している間、永遠にメモリ上に残り続ける
List globalUserCache = [];

class ApiClient {
void fetchAndCache() async {
final users = await fetchUsersFromApi();
globalUserCache = users; // 参照を書き換え
}
}

この実装の何が問題か?

1. 予期せぬ副作用(Spaghetti State):
アプリのどの場所からでも `globalUserCache` を書き換えられるため、「いつ、誰が、なぜデータを書き換えたのか」を追跡できなくなる。デバッグ地獄の始まりだ。
2. メモリリーク:
もしこの `globalUserCache` に何万件ものデータが入り、さらにそれがウィジェットやコンポーネントのライフサイクルと切り離されて保持され続けた場合、GCはそれを回収できない。不要になった画面を閉じても、メモリが解放されない現象(メモリリーク)が発生する。
3. Isolate間での共有不可能性:
Dartはシングルスレッド(正確にはIsolateという独立したメモリ空間を持つ単位)で動作する。グローバル変数は「そのIsolate内」で共有されるため、マルチスレッドのような安全な同期機構がないまま書き換えを行うと、競合状態(Race Condition)のリスクを常に孕むことになる。

—

2. `var`, `final`, `const` の決定的な違い(コンパイル時評価の魔法)

Dartの変数宣言における3つのキーワードは、単なる「値の書き換え可否」を表すものではない。「いつメモリに配置され、どう評価されるか」というコンパイラの最適化に直結する極めて重要な仕様だ。

| キーワード | 評価タイミング | メモリ上の扱い | 再代入 | 値の変更 (Mutation) |
| :— | :— | :— | :— | :— |
| `var` | 実行時 (Runtime) | ヒープ / スタック | 可 | 可 |
| `final` | 実行時 (Runtime) | ヒープ (一度だけ確定) | 不可 | 構造による(後述) |
| `const` | コンパイル時 (Compile-time) | 定数プール (Canonicalized) | 不可 | 完全なイミュータブル |

特に注目すべきは `const` だ。`const` を使った値は、Dartのコンパイラがビルド時に評価し、バイナリ内の「定数プール」に焼き付ける。これにより、アプリの起動時にはすでにメモリ上の特定の位置に存在し、何度呼び出しても同一のインスタンス(Canonicalized)が返されるため、メモリ割り当てのオーバーヘッドがゼロになる。

—

3. 実務で使える!堅牢な定数・状態設計のプロダクションコード

では、実際のフロントエンド開発やAPI連携の現場において、どのように設計すべきか。
「変更不可能な設定値・定数」と「動的な状態管理」を綺麗に分離した、保守性の高いサンプルコードを提示する。

模範解答:`static const` による堅牢な定数管理と、DI(依存性注入)による安全な状態管理

import ‘dart:async’;

// ==========================================
// 1. 定数層 (Compile-time Constants)
// ==========================================
class AppConfig {
// プライベートコンストラクタにしてインスタンス化を防ぐ(Namespacing)
AppConfig._();

// プリミティブな定数は static const でコンパイル時定数にする
static const String appName = ‘Enterprise Web Client’;
static const Duration apiTimeout = Duration(seconds: 10);
static const int maxRetryCount = 3;

// 複雑な構造体も const コンストラクタで定義すればコンパイル時定数になる
static const Map defaultHeaders = {
‘Content-Type’: ‘application/json’,
‘X-Client-Version’: ‘2.1.0’,
};
}

// ==========================================
// 2. データ構造層 (Immutable Data)
// ==========================================
class UserProfile {
final String id;
final String name;

// const コンストラクタにより、このオブジェクトもコンパイル時・あるいは最適化されたメモリ配置が可能に
const UserProfile({required this.id, required this.name});
}

// ==========================================
// 3. 状態管理・サービス層 (Dependency Injection)
// ==========================================
/// グローバル変数ではなく、クラスのインスタンス(サービス)としてスコープを絞る。
/// RiverpodやProviderなどのDIコンテナ経由で注入して使用することを想定。
class UserSessionManager {
// 内部的な可変状態はプライベートにし、外部からの直接変更をシャットアウトする
UserProfile? _currentUser;

// 状態の変更をリアクティブに通知するストリーム
final _userController = StreamController.broadcast();
Stream get userStream => _userController.stream;

UserProfile? get currentUser => _currentUser;
bool get isAuthenticated => _currentUser != null;

/// ログイン処理(副作用をここにカプセル化する)
void login(UserProfile user) {
_currentUser = user;
_userController.add(_currentUser);
print(‘[Session] User logged in: ${user.name}’);
}

/// ログアウト処理(メモリクリアの責任を明確にする)
void logout() {
_currentUser = null;
_userController.add(null);
print(‘[Session] User logged out, session cleared.’);
}

/// 破棄処理
void dispose() {
_userController.close();
}
}

// ==========================================
// 4. 実行エントリポイント
// ==========================================
void main() async {
print(‘— App Initialization —‘);

// AppConfig.appName はコンパイル時に確定しているため高速にアクセス可能
print(‘Starting ${AppConfig.appName} with timeout: ${AppConfig.apiTimeout.inSeconds}s’);

// セッションマネージャーのインスタンス化(DIコンテナが管理するスコープを想定)
final sessionManager = UserSessionManager();

// データの流動を監視
sessionManager.userStream.listen((user) {
if (user != null) {
print(‘UI Update: Welcome back, ${user.name}!’);
} else {
print(‘UI Update: Redirecting to Login screen.’);
}
});

// ユーザーのログイン動作をシミュレート
final mockUser = UserProfile(id: ‘usr_999’, name: ‘Alice Architecture’);
sessionManager.login(mockUser);

// 一定処理の後、ログアウトしてメモリ(状態)をキレイに片付ける
sessionManager.logout();

// リークを防ぐために必ずクリーンアップ
sessionManager.dispose();
}

—

4. テクニカルリードからの最終チェックリスト

コードレビューでこれらのパターンを見かけたら、以下の観点で指摘を飛ばしてほしい。

1. 「その `var` や `final`、トップレベルに置く必要ある?」

  • クラスのスコープ、あるいはDIコンテナ(Riverpod, Provider, GetIt等)の管理下に置けないか検討させること。

2. 「定数に `const` を使っているか?」

  • マップやリスト、設定値であっても、不変なものは `static const`(または `const` コンストラクタ)を徹底させ、無駄なランタイムのオブジェクト生成コストを削る。

3. 「状態の変更経路が隠蔽されているか?」

  • 外部から `object.state = newValue` のように直接書き換えられる設計になっていないか。必ずセッターやメソッド経由にし、バリデーションやイベント通知(Stream / StateNotifier等)を挟むアーキテクチャに誘導する。

Dartは非常に強力で洗練された言語だ。その言語仕様の恩恵を最大限に引き出し、パフォーマンスが高く、かつバグの入り込む余地のない美しいコードベースを共に築き上げていこう。

次回のプルリクエストを楽しみにしている。

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