BEMAロゴ

エンジニアの
成長を支援する
技術メディア

【Dart/Flutter】Record×クロージャで実現する、モダンで「軽量」なStrategy Pattern。クラス乱立とDIのボイラープレート削減

はじめに:なぜ今「継承」を疑うのか

こんにちは、株式会社メンバーズ Cross ApplicationOpen in new tabカンパニーの田原です。

「機能を増やすたびに、クラス階層が複雑になり、修正の影響範囲が読みきれない……」

オブジェクト指向プログラミング(OOP)を学んだ多くのエンジニアが直面する壁、それが「継承による設計の硬直化」です。教科書的な「is-a関係」は、現代のスピード感ある開発において、しばしば密結合という名の負債に変わります。

これに対する回答が、「Composition Over Inheritance(継承より合成を)」という原則です。そして、機能を「親から引き継ぐ」のではなく、独立した部品として「組み合わせて作る」という、この思想を最も美しく体現するのが、Strategy Pattern(ストラテジーパターン)です。

参考

しかし、従来のStrategy Patternは「クラスの乱立」や「ボイラープレートの多さ」という「設計の重さ」が課題でした。特に、シンプルな機能のために抽象クラスや実装クラスを大量に定義する必要があり、DI(依存性注入)における設定のボイラープレートも無視できませんでした。

本記事では、Dartの Record × クロージャ を使い、この重厚なパターンを極めてシンプルに記述する、モダンな抽象化パターンを解説します。

  • 継承の負債: 強固すぎる結びつき、壊れやすい基底クラス

  • 本記事の解決策: Recordによる「軽量な抽象化」と、クロージャによる「即時実装」

抽象クラス・実装クラスの乱立を防ぎ、ボイラープレートを極限まで削ぎ落とす。型安全性を保ったまま「変化に強いしなやかな設計」を手に入れる、Dart/Flutter開発における決定版ガイドです。

継承の限界:脆弱な基底クラス問題(Fragile Base Class Problem)

継承の最大のリスクは、親クラスの「良かれと思った改善」が、その詳細を知らない子クラスをいとも簡単に破壊してしまうことです。これが「脆弱な基底クラス問題(Fragile Base Class Problem)」です。

一見、カプセル化によって守られているように見える親クラスの「内部実装」ですが、継承関係においては、その実装の詳細が暗黙の依存関係として子クラスに露出してしまいます。

今回は、文字列を加工するシンプルな Formatter の例で、その脆弱性を確認してみます。

期待通りの動作

まずは、基本となる「大文字変換」を行うクラスを継承して、前後に装飾をつける子クラスを作成します。

// 親クラス:基本的な加工ロジック
class TextProcessor {
  // 単語ひとつを加工
  String process(String text) => text.toUpperCase();

  // 複数の単語を結合して加工
  String processAll(List<String> words) {
    // 内部で process を呼び出して処理している
    return words.map((w) => process(w)).join(' ');
  }
}

// 子クラス:加工に「装飾」を加えたい
class DecoratedProcessor extends TextProcessor {
  @override
  String process(String text) {
    // 親の加工(大文字化)に星をつける
    return "★${super.process(text)}★";
  }
}

void main() {
  var processor = DecoratedProcessor();
  print(processor.processAll(['dart', 'composition']));
  // 実行結果: "★DART★ ★COMPOSITION★" (期待通り)
}

脆弱性が露呈する瞬間

ここで、親クラスの開発者が「1個ずつ変換するより、全部結合してから一気に大文字化したほうが効率的だ」と考えて、processAll を最適化します。

// 修正された親クラス
class TextProcessor {
  String process(String text) => text.toUpperCase();

  String processAll(List<String> words) {
    // 【最適化】個別に process を呼ぶのをやめ、結合してから一気に変換
    // これにより、メソッド呼び出しのオーバーヘッドを減らした(つもり)
    return words.join(' ').toUpperCase();
  }
}

void main() {
  var processor = DecoratedProcessor();
  print(processor.processAll(['dart', 'composition']));

  // 実行結果: "DART COMPOSITION"
  // 装飾(★)が付与されない。DecoratedProcessorの機能が沈黙する。
}

なぜこの例が「本質的な問題」を示しているのか

このコードの破壊は、単なるプログラミングミスではなく、継承という構造が持つ3つの構造的欠陥を浮き彫りにしています。

  • 契約の欠如
    親クラスの processAll が内部で process を使うことは、単なる「実装の都合」であり、「公開された契約(約束)」ではありませんでした。

  • 自己呼び出し(Self-invocation)の罠
    親クラスが自身のメソッドを内部でどう利用しているかを完全に把握していなければ、安全にオーバーライドすることすらできないことを示しています。

  • カプセル化の崩壊
    親クラスが内部ロジックを修正しただけで子クラスの機能が消失する。これは、親の実装が事実上、子クラスに筒抜け(ホワイトボックス)になっていたことを意味します。

「再利用のために継承する」という選択が、実は親クラスの改善(リファクタリング)を不可能にし、子クラスの挙動を不安定にするという皮肉な結果を招いているのです。

解決策としてのストラテジーパターン

継承の脆さを克服する鍵は、「振る舞い(アルゴリズム)」をクラスから切り離し、外部から注入することにあります。この設計思想を体現するのがストラテジーパターンです。
「~である(is-a)」という呪縛を解き、「~を部品として持っている(has-a)」という合成の形へ、4つのアプローチで検討します。

Step 1 | 古典的なInterfaceパターン:堅牢さの代償としての「重さ」

最も堅牢で教科書的な方法です。継承が「親の実装を隠れて覗き見る」ものだったのに対し、インターフェースによる合成は「明文化された契約のみに依存する」手法です。

// 抽象化したストラテジー(Interface)
abstract interface class TextTransformStrategy {
  String apply(String text);
}

// 具体的なストラテジーのクラス化
class StarDecorator implements TextTransformStrategy {
  @override
  String apply(String text) => '★${text.toUpperCase()}★';
}

class TextProcessor {
  final TextTransformStrategy strategy; // 合成(has-a)
  TextProcessor(this.strategy);

  String processAll(List<String> words) {
    // 親の内部実装を気にする必要がない。注入されたストラテジーを信じて使うだけ。
    return words.map((w) => strategy.apply(w)).join(' ');
  }
}

なぜこれが「継承」より優れているのか

最大の利点は、TextProcessor(使う側)と StarDecorator(使われる側)の間に、物理的な壁ができることです。

  • カプセル化の保護: 
    TextProcessor が processAll の内部実装をどう最適化しようと、strategy.apply を呼んでいる限り、子クラス(具体的なストラテジー)を壊すことはありません。逆もまた然りです。

  • ホワイトボックスからブラックボックスへ:
    継承のように内部実装を共有しないため、お互いのコードを「中身のわからない箱」として扱えます。

しかし、直面する「設計の重さ」

この方法は非常に堅牢ですが、実務においては以下のボイラープレート(定型文)の壁に突き当たります。

  1. クラスの乱立: 
    「ちょっとだけ装飾を変えたい」と思っても、その都度 class NewStrategy implements ... という新しいクラスファイルを作る必要があります。

  2. 記述の冗長性: 
    インターフェース定義、クラス定義、メソッドのオーバーライド……。本来やりたい「文字列の加工」という一行のロジックに対して、あまりにも多くの儀式が必要になります。

  3. 認知負荷: 
    プロジェクトが大きくなると、小さなストラテジーのためだけに作られた大量のクラスファイルがディレクトリを圧迫し、コードの全体像を追うのが難しくなります。

この「堅牢さは欲しいが、もっと手軽に書きたい」というジレンマが、次のステップへの原動力となります。

Step 2 | 軽量なクロージャ(関数)パターン:機能セットとしての「構造化の欠如」

インターフェースによる抽象化は堅牢ですが、ロジックがシンプルであればあるほど、クラス定義という「儀式」が重くのしかかります。そこで、「振る舞い=関数」と割り切り、クロージャ(関数オブジェクト)を直接注入するのが現代的なアプローチです。

class TextProcessor {
  // 型としてのクラスではなく、関数を直接受け取る
  final String Function(String) transform;
  TextProcessor(this.transform);

  String processAll(List<String> words) => words.map(transform).join(' ');
}

// 利用時:その場でロジックを注入
final processor = TextProcessor((s) => '★${s.toUpperCase()}★');

クラスを捨てて「関数」を選ぶメリット

ステップAと比較すると、設計の自由度が劇的に向上していることがわかります。

  • ボイラープレートの徹底排除: 
    abstract class も implements も不要です。必要なのは「引数と戻り値の形(シグネチャ)」を合わせることだけです。

  • 定義の局所化: 
    特定の場所でしか使わないロジックを、わざわざグローバルなクラスとして定義する必要がありません。使う場所で、使う瞬間に定義できます。

  • 高階関数のパワー: 
    map や where といったDartの標準機能とシームレスに組み合わさるため、コードがより宣言的(宣言した通りの動き)になります。

限界:振る舞いが増えると破綻する

この手法は非常に強力ですが、「注入したい振る舞いが複数になったとき」に弱点が露呈します。
例えば、加工ロジックだけでなく、前処理(sanitize)や後処理(log)も外から注入したい場合、コンストラクタはどうなるでしょうか。

class TextProcessor {
  final String Function(String) transform;
  final String Function(String) sanitize;
  final void Function(String) log;

  TextProcessor({
    required this.transform,
    required this.sanitize,
    required this.log,
  });
}

この「バラバラの関数を渡す」設計には、実務上の大きな壁が2つ立ちはだかります。

  1. 「セット」としての再利用ができない
    サニタイズ、変換、ログという3つの役割は、多くの場合「一つの機能セット(プラグイン)」として扱いたいものです。しかし、名前付き引数はあくまでそのクラスに閉じた定義です。もし別のクラスでも同じ3つの機能を使いたい場合、再び同じ3つの関数定義を書き直さなければなりません。

  2. 依存関係のバケツリレー
    上位層から下位層へ、この「機能セット」を渡したい場合を想像してください。

// 名前付き引数だと、受け渡すたびに関数を一つずつバラさなければならない
class HogeService {
  HogeService({
    required this.onLog,
    required this.onTransform,
    ...
  });
  
  void init() {
    // 下位層へ渡す際に、また一つずつ引数を指定する手間(バケツリレー)が発生
    final processor = TextProcessor(
      log: onLog,
      transform: onTransform,
      sanitize: (s) => s, 
    );
  }
}

関数がバラバラの引数として扱われているため、他のクラスで使い回す「セット」として再利用できず、階層を跨ぐ際の「バケツリレー」も発生します。この煩雑さが、再び「構造化(一つにまとめること)」へと向かわせます。

Step 3 | クラスによるパッケージ化:柔軟性を奪う「名義的型付け」

クロージャがバラバラになる問題を解決するため、誰もが一度は「設定用のクラス」にまとめることを検討します。これは設計に「ドメインの名前」を与える重要なステップです。

// 1. 振る舞いをまとめるための「専用クラス」を定義
class TextActions {
  final String Function(String) transform;
  final String Function(String) sanitize;
  final void Function(String) log;

  TextActions({
    required this.transform,
    required this.sanitize,
    required this.log,
  });
}

// 2. 利用する側はその「クラス名」に依存する
class TextProcessor {
  final TextActions actions; // 型(名前)に依存している
  TextProcessor(this.actions);

  void run(String input) {
    final clean = actions.sanitize(input);
    final result = actions.transform(clean);
    actions.log(result);
  }
}

// 3. 使うための「儀式」:インスタンス化が必要
void main() {
  final myActions = TextActions(
    transform: (s) => s.toUpperCase(),
    sanitize: (s) => s.trim(),
    log: (s) => print('Result: $s'),
  );

  final processor = TextProcessor(myActions);
  processor.run('  hello dart  ');
}

この手法のメリット:ドメインの「名前」による秩序

バラバラだった関数が TextActions という一つの型に集約されることで、コードに意味(セマンティクス)が生まれます。「何を渡すべきか」が明確になり、ドキュメント性が向上するのは、大規模開発において大きな利点です。

この手法に潜む「設計の重さ」

  1. 「名前」が強制する結合 (Nominal Typing) 
    もし別のパッケージで DataActions という、これと全く同じ構造(transform, sanitize, log)を持つクラスがあったとしても、TextProcessor に渡すことはできません。TextProcessor は「TextActions という名前の型」を要求しているからです。この「名前の一致」を求める制約が、コードの再利用を妨げ、不要な import を増やします。

  2. 実体化という「儀式」のコスト 
    ただ「関数をセットで渡したい」だけだとしても、インスタンス化というライフサイクル管理コストが発生します。振る舞いを定義したいだけなのに、データ構造としてのライフサイクル管理まで引き受けるのは、設計として過剰(オーバーエンジニアリング)になりがちです。

  3. ボイラープレートの再生産 
    結局、フィールド宣言や代入を何度も繰り返すことになります。

Step 4 | Record × クロージャ による「モダンな合成」

本記事の核心です。Interface(重すぎる)」「Closure(バラバラすぎる)」「Class(名前が固すぎる)」という全てのトレードオフを解消する答えが、Recordとクロージャの組み合わせです。

Recordを使えば、Interfaceのような「名前付きの安心感」と、クロージャの「手軽さ」を完璧なバランスで両立できます。

// 1. クラス定義を必要としない軽量なインターフェースとして機能する、ストラテジーのセットをRecord型で定義
typedef TextActions = ({
  String Function(String) transform,
  String Function(String) sanitize,
  void Function(String) log,
});

class TextProcessor {
  // 複数の振る舞いを「1つの型」として合成
  final TextActions actions;

  TextProcessor(this.actions);

  void run(String input) {
    // 名前付きフィールドにより、何を実行しているかが一目瞭然
    final clean = actions.sanitize(input);
    final result = actions.transform(clean);
    actions.log(result);
  }
}

// 2. 利用時:クラスを作らず、Recordリテラルで「即時実装」して渡す
final processor = TextProcessor((
  transform: (s) => s.toUpperCase(),
  sanitize: (s) => s.trim(),
  log: (s) => print('Result: $s'),
));

なぜ「Record × クロージャ」が最強の組み合わせなのか

この設計がもたらす恩恵は、単に「短く書ける」ことだけではありません。設計の本質を「名前(型名)」から「構造(振る舞い)」へとシフトさせます。

  1. インターフェースの「即時実装」
    従来のストラテジーパターンでは、新しいストラテジーを追加するたびに「ファイル作成 → クラス定義 → implements → override」という手順が必要でした。 Recordなら、Recordリテラルそのものが「匿名の実装クラス」として機能します。クラス定義という重い工程をスキップして、その場で契約を完了できる圧倒的な軽快さが得られます。

  2. 「名前」ではなく「構造」でつながる
    Recordは「構造的型付け」Open in new tabに近い性質を持っています。これにより、例えば、別のパッケージで定義された全く別のRecordであっても、フィールド名と型が一致していれば、Recordはそれを受け入れます。特定のクラス名に依存せず、「必要な関数が揃っているか」という構造だけで型が一致することで、不要な import や依存関係の連鎖から解放されます。

  3. ボイラープレートの消滅
    これまでの「型のための儀式(フィールド宣言、コンストラクタ代入、@override)」は、数行の typedef とRecordリテラルに凝縮されます。 しかし、単なるクロージャのバケツリレーとは異なり、Recordの名前付きフィールドのおかげで、呼び出し側では actions.transform() のように、その関数が「何の役割(Role)」を担っているかが明示され、クラスと同等の可読性が維持されます。

【結論】抽象化の選択肢:Record / Closure / Class の使い分けガイド

「Record × クロージャ」は強力ですが、決して全ての abstract class を置き換える万能薬ではありません。実務で迷わないための判断基準を整理します。

判断基準:Behavior, State, Name

設計に迷ったら、その対象が「振る舞い(Behavior)」を重視するのか、「状態(State)」を重視するのか、そして「名前(Name)」が必要かという視点で判断します。

Q1. 【Behavior】単一の純粋なロジックのみを差し替えたいか?
YES → 【クロージャ(単体)】
バリデーションや数値計算など、ボイラープレートを最小限に抑え、関数のシグネチャ(引数と戻り値)だけで成立する最小単位の抽象化です。

Q2. 【State】「ライフサイクル」や「複雑な内部状態」を持つか?
YES → 【Interface + Class】
DB接続の維持、キャッシュの保持、状態変化の通知など、「そのオブジェクトが今何を知っているか」という状態管理が主目的である場合は、クラスによるカプセル化が不可欠です。

Q3. 【Name】ドメインにおける重要な概念(名前を持つべき役割)か?
YES → 【Class + クロージャ】
税計算(TaxCalculator)や販促ポリシー(PromotionPolicy)のように、ビジネス上の固有名詞を持つ「役割(Role)」であるなら、クラスを定義すべきです。「名義的型付け」による名前そのものが、ドメインの境界を明示するドキュメントになります。

NO → 【Record × クロージャ】
プラグイン設計やDIなど、特定のクラス名に縛られず、「構造(何ができるか)」を優先したい場合に最強の選択肢となります。複数のクロージャを名前付きフィールドで束ねることで、クラスの重さを排除しつつ、インターフェースのような一貫性を確保します。

実践上の注意:メモリ管理と「キャプチャの罠」

Recordとクロージャを組み合わせた設計は強力ですが、Dartのメモリ管理(ガベージコレクション)の仕組みを正しく理解していないと、意図しないメモリリークを引き起こす可能性があります。

特に注意すべきは、クロージャによる 「暗黙の this キャプチャ」 です。

メモリリークのメカニズム

Dartのクロージャは、実行に必要な「環境」を保持(キャプチャ)します。クラスのインスタンスメソッドを直接 Record に渡すと、その関数は this(インスタンス全体)を袋詰めにして保持し続けます。

もしその Record がアプリの生存期間中ずっと存在する変数(グローバルなレジストリなど)に保存された場合、本来破棄されるべきインスタンスがメモリに残り続けることになります。

// アプリ実行中ずっと生き続けるグローバルな変数
typedef Handler = ({void Function() execute});
Handler? globalRegistry;

class DataService {
  // 数十MBの巨大なキャッシュデータ
  final List<int> _hugeCache = List.generate(10000000, (i) => i);
  final String _prefix = '[LOG]';

  void _log(String msg) => print("$_prefix $msg");

  void register() {
    // 【罠】インスタンスメソッドを直接渡すと、this(と巨大なキャッシュ)を道連れにする
    globalRegistry = (execute: () => _log('Registered'));
  }
}

この「参照の鎖」は、DartのGC(ガベージコレクタ)がどれほど賢くても断ち切ることができません。なぜなら、globalRegistry が生きている限り、そこから辿れる this は「まだ必要なデータ」だと判断されるからです。

リスクを回避する2つの処方箋

Record × Closureによる設計を「負債」にしないためには、キャプチャの範囲を最小限に抑えるための明確なルールが必要です。

A. インスタンス変数に依存しないなら static メソッドを渡す

もし注入するロジックがインスタンスの状態(フィールド)を必要としないのであれば、static メソッドとして定義するのが最も安全な選択です。static メソッドは構造的に this を持たないため、誤ってインスタンスをキャプチャするリスクを根底から排除できます。

class SafeService {
  final List<int> _hugeCache = List.generate(10000000, (i) => i);

  // staticにすることでインスタンスから切り離す
  static void _staticLog(String msg) => print('[SAFE] $msg');

  void register() {
    globalRegistry = (execute: () => _staticLog('Safe')); // 安全
  }
}

B. 必要な値だけをローカル変数にコピーする

どうしても設定値などのインスタンス変数が必要な場合は、this 全体ではなく、「その瞬間に必要な値」だけをローカル変数にコピーし、その変数だけをクロージャに閉じ込めます。

class OptimizedService {
  final List<int> _hugeCache = List.generate(10000000, (i) => i);
  final String _prefix = '[OPT]';

  void register() {
    // インスタンス変数から値だけを抽出(コピー)
    final prefix = _prefix;

    globalRegistry = (
      execute: () {
        // this ではなく、ローカル変数 prefix だけを保持する
        print('$prefix Action executed');
      },
    );
  }
}

まとめ:設計を「しなやか」にするということ

これまでの設計は、木が枝分かれするような「継承」のモデルが中心でした。しかし、現代の複雑で変化の激しいアプリケーションには、必要なパーツをその場で選び、ブロックのように組み替える「合成」のモデルが求められています。

  • 継承は「階層」を作り、強固な結合を生む。

  • 合成は「ネットワーク」を作り、しなやかな結合を生む。

DartのRecordは、単なるデータの入れ物ではありません。クラス定義という重い儀式を介さずに、「振る舞いの構造」だけを定義・交換できる自由を提供してくれました。

「この機能、わざわざクラスにする必要があるだろうか?」 「そのインターフェース、Recordで代替できないだろうか?」

そう問い直す一歩が、あなたのコードを「脆弱な基底クラス」の呪縛から解き放ち、変化に強いしなやかなシステムへと変えていくはずです。

最後までお読みいただき、ありがとうございました!

この記事が役に立ったと思ったら、
ぜひ「いいね」とシェアをお願いします!

リンクをコピーXでシェアするfacebookでシェアする

この記事を書いた人

田原 葉
田原 葉
2024年にメンバーズに中途で入社。前職はiOSエンジニア。現在はCross ApplicationカンパニーでFlutter技術をメインにモバイルアプリ開発支援を担当。
詳しく見る
ページトップへ戻る