柔軟性と汎用性
ソフトウェアの柔軟性(flexibility)と汎用性(generality)について、散らばった定義を並べたうえで、なぜ確保するのか、どうレベルを捉えるか、どう達成するか、どこにコストがかかるのかを整理する。両者は混同されやすいが別の軸で、共通項として実装非依存性を持つ。
柔軟性と汎用性とは何か
柔軟性
柔軟性には単一の標準定義がない。分野と論者によって強調点が違う。代表的な定義を並べる。
Sussman & Hanson『Software Design for Flexibility』(2021): 既存のコードを修正することなく、コード追加によって新機能を適応させられる能力。"The best systems are evolvable; they can be adapted for new situations by adding code, rather than changing the existing code." これを additive programming と呼ぶ。 Brooks "No Silver Bullet" (1986): ソフトウェアに固有の本質的困難の一つ。"The software entity is constantly subject to pressures for change... All successful software gets changed." changeability は技術革新では解消できない本質的困難とされる。 ISO/IEC 25010: Modifiability を「欠陥導入や既存品質の劣化なく、有効かつ効率的に修正できる度合い」と定義する。Maintainability の副特性(Modularity / Reusability / Analysability / Modifiability / Testability) の一つに位置づけている。 Fowler "Who Needs an Architect?" (2003): 柔軟性を直接定義せず、逆側から「変更困難(hard to change)と認識されるものがアーキテクチャだ」とする。柔軟性を確保する設計の対象は、変更困難になりやすい部分(アーキテクチャ層)に重なる。 これらを踏まえ、ここでは以下を柔軟性の定義として採用する。
既存の振る舞いを壊さず、妥当なコストで変更を吸収できる度合い
汎用性
汎用性 (generality) も論者によって強調点が違う。代表的な定義を並べる。
Boehm『Characteristics of Software Quality』(1978): 品質特性ツリーの primitive characteristic として "Generality" を独立項目に置く。"the breadth of the potential application of program components"。一つのコンポーネントが適用されうる範囲の広さ Davis『201 Principles of Software Development』(1995) Principle "Build Generality into Software": "A generalized solution, that solves not only one but many problems, is better than a specific one." Stroustrup (Concept-based Generic Programming) が引用する Musser–Stepanov の generic programming 定義: "Generic programming centers around the idea of abstracting from concrete, efficient algorithms to obtain generic algorithms that can be combined with different data representations to produce a wide variety of useful software." Kernighan & Pike『The Practice of Programming』(1999) Ch.4 "Interfaces": "Basic principles — simplicity, clarity, generality — form the bedrock of good software." インタフェース設計の品質軸の一つに置く これらを踏まえ、ここでは以下を汎用性の定義として採用する。
一つの実装が、呼び出し側に実装詳細を持ち出させずに扱える入力・文脈の範囲の広さ
なお Boehm 以降、ISO/IEC 9126 と後継の 25010 では汎用性は独立項目として残らず、再利用性が保守性の副特性として残った。汎用性そのものを独立の品質特性として定義した最後の品質モデル (quality model) は Boehm 1978 である。Kernighan & Pike や Meyer らは品質モデルとしてではなく、インタフェース設計や再利用の原則として汎用性に言及している。
汎用性と柔軟性は別の軸である
汎用性が現時点で扱える範囲を指すのに対し、柔軟性は将来の変更を既存実装が吸収できる度合いを指す。両者は独立しうる。
高汎用×低柔軟: 汎用DBアクセスヘルパ execute(sql, params)。任意のSQLを発行できる(汎用性は高い)反面、戻り値の構造は呼び出し側が書いたSQLに依存するので、テーブル定義変更やRDBMS乗り換えで呼び出し箇所をすべて書き直す必要がある
低汎用×高柔軟: Repositoryインターフェース UserRepository.findById(id)。扱える操作は「ユーザをIDで引く」だけに限定されている(低汎用)が、裏のストアをRDBからDynamoDBに替えても、キャッシュ層を挟んでも、モック実装を差し込んでも、インターフェースと呼び出し側は変わらない(高柔軟)
汎用性を上げると、柔軟性は下がることがある。execute(sql, ...) のように広い入力を受け付ける面を作ると、その面を通じて呼び出し側が今の実装の詳細(SQL方言)に依存し、変更時に壊れる箇所が汎用性の広さと比例して増える。
定義から因子への分解
柔軟性の定義「既存の振る舞いを壊さず、妥当なコストで変更を吸収できる度合い」を設計上の因子に分解する。「既存の振る舞いを壊さず」は、呼び出し側への約束 (事後条件) が変更後も保たれることを指す。「変更を吸収」には実装差し替えと定義域拡張の両方が含まれる。
これ以降では、まず両者に共通する因子として実装非依存性 I を取り出し、次に呼び出し側への約束の強さとして契約強度 P、最後に受け付ける入力の範囲として定義域の広さ D を導入する。最終的に柔軟性 = P × I、汎用性 = D × I という分解に至る。
仕様の実装非依存性(I)
汎用性と柔軟性を分解すると、共通の項として「仕様が実装の詳細に依存しないこと」が現れる。この性質は計算機科学の異なる領域で繰り返し定式化されてきた。
Parnas 1972 (情報隠蔽) はモジュール分解の基準として次のように述べる。
We propose instead that one begins with a list of difficult design decisions or design decisions which are likely to change. Each module is then designed to hide such a decision from the others. (原典 PDF) 実装詳細(変更されうる設計決定)をインターフェースの背後に隠すことで、仕様を実装から切り離す。
Mitchell 1986 (表現独立性):
Programs should not depend on the way stacks are represented, only on the behavior of stacks with respect to push and pop operations. (POPL 1986) プログラムはスタックの表現方法ではなく、push/popの振る舞いだけに依存すべきだとする。ADTの異なる実装が仕様上相互置換可能であることを representation independence として形式化した。
Reynolds 1983 (parametricity) / Wadler 1989 (theorems for free):
Reynoldsが定式化した parametricity (abstraction theorem) は、型による抽象化が実装の一様性を強制することを保証する(PDF)。Wadlerはその系として、型シグネチャだけから関数の性質が導かれることを示した。 Write down the definition of a polymorphic function on a piece of paper. Tell me its type, but be careful not to let me see the function's definition. I will tell you a theorem that the function satisfies. (PDF) 実装を見ずに、型だけから関数の振る舞いに関する定理が出てくる。仕様が実装に依存しないことを型のレベルで強制した形である。
Nygard 2024 (Constrain the Provider to Liberate Callers):
Constrain the provider to liberate callers. ... assumptions in the provider constrain the caller. We should invert this: limit the provider's ability to make assumptions thereby allowing callers to use it in various situations.
ここで「プロバイダの assumptions を制約する」とは、プロバイダが入力の具体構造について持つ仮定を減らすことであり、プロバイダが呼び出し側に与える約束(後述の契約強度)を弱めることではない。Nygardはこの原則を関数型シグネチャだけでなくサービス境界やAPI設計にも適用する。
Liskov 1987 (置換可能性):
If for each object o₁ of type S there is an object o₂ of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o₁ is substituted for o₂, then S is a subtype of T. (PDF) 型Sを型Tのどこに置いても、Tに基づいて書かれたプログラムの振る舞いが変わらないとき、SはTの部分型である、という定式化。実装(部分型)を差し替えても仕様(上位型)に依存するコードは壊れない、という性質を表す。LSPの成立条件は、基底型Tの契約が呼び出し側の依存を縛れるほど明確であることと、部分型Sがその契約を具体実装によらず保つことの両方を要する。後述する契約強度と実装非依存性に対応している。
5者の対象と関心は異なる。対比すると次のようになる。
table:table
概念 対象 主な関心 軸上の位置
Parnas 1972 モジュール境界 変更されうる設計決定の隠蔽 I
Mitchell 1986 ADT 表現に依存しない振る舞い I
Reynolds / Wadler 多相関数 型からの振る舞い導出 I
Nygard 2024 関数・API境界 Pre を絞り実装を強制 I を上げる手段
Liskov 1987 部分型関係 置換可能性 I と P の成立条件
「実装詳細が仕様の意味に漏れない」という共通核を、それぞれ別の対象で述べている。I の半順序はこの共通核を指す。Nygard は事前条件を絞ることで実装を強制する処方で、I そのものの定義ではなく I を上げる手段として位置づける。Liskov は実装を差し替えても仕様が保たれる条件を、基底型の契約 (P) と部分型側の実装非依存性 (I) の両方から与えている。後段の因数分解では Liskov & Wing 1994 の事前条件弱化と事後条件強化の積を P × I (柔軟性) と D × I (汎用性) の2つに組み換えるが、その組み換え前の Liskov の定式化そのものは柔軟性側にも汎用性側にもまたがる基礎である。
以降これらの共通項を「仕様の実装非依存性」と呼ぶ。
仕様の契約強度(P)
実装非依存性だけでは柔軟性を説明しきれない。
JSON.parse(s) → any (TypeScript/JavaScript) を例に取る。定義域は広い(任意のJSON文字列)。パーサ実装を差し替えても結果はECMA-404に従って一意なので、実装非依存性も成立している。それでも呼び出し側は柔軟にならない。返ってきたデータの構造が変わるたびに呼び出し側が壊れる。
同じ問題が、Map<String, Object> を引数に取るAPI、execute(sql) → ResultSet (結果型は決まっているが何のカラムが返るかはSQL次第)、ドメイン非特化なHTTPエンドポイント POST /api/data、バイト列を読み書きする read(fd, buf, n) にもある。実装非依存性は満たすが、呼び出し側の変更耐性は低い。
共通の原因は、仕様が呼び出し側に対して十分な約束をしていないことである。JSON.parse が呼び出し側に約束しているのは「何らかのJSON値を返す」までで、呼び出し側が必要とする「それがUserなのかOrderなのか、どんなフィールドを持つのか」は約束していない。呼び出し側は自分でその構造を解釈する必要があり、構造が変わると解釈する側のコードも影響を受ける。
UserRepository.findById(id) → User の場合、呼び出し側は「Userエンティティの不変条件を満たす値が返る、もしくは存在しないことが示される」という約束に依存できる。永続化の裏側が変わっても、この約束が保たれる限り呼び出し側は影響を受けない。
この因子を、ここでは仕様の契約強度と呼ぶ。Design by Contract の事後条件 Post の強さとして定義する。
契約 C₂ の Post₂ が C₁ の Post₁ より強い ⇔ Post₁ ⇒ Post₂
以下 Pre / Post / ⇒ は Design by Contract 由来の記法である。⇒ は論理的含意を指し、機械検査可能性は含意しない。
事後条件が強まるほど、呼び出し側にとっての約束が増える。
例:
JSON.parse(s) → any の Post は「返り値は何らかのJSON値」
parseUser(s) → User | Error の Post は「返り値はUserの不変条件を満たすか、Error」
後者のPostは前者のPostを含意するので、契約は後者が強い
これは全順序ではなく半順序である。「結果を5秒以内に返す」と「結果がUser型を満たす」のように互いに含意しない契約は比較不能で、強弱は同じ関心領域を共有するペア間でしか比較できない。
誰にとっての柔軟性か
事後条件を強くすると柔軟性が高くなる、という関係は直感に反するところがある。約束を増やせばプロバイダ側の実装の自由度は下がるからである。これは「誰にとっての柔軟性か」を明示しないと混乱する。
プロバイダから見た自由度: 事後条件を弱くするほど、内部実装の選択肢が増える
呼び出し側から見た柔軟性: 事後条件を強くするほど、約束に依存できるので、永続化機構などの実装変更があっても呼び出し側のコードは変更不要になる
ここでの柔軟性は呼び出し側視点の性質である。プロバイダが実装を差し替える事態が実際に発生するかどうかとは独立に、差し替え可能性が成立していれば柔軟性があると扱う。ケース1で「永続化を RDB から DynamoDB に替えても呼び出し側は壊れない」と述べるとき、差し替えが実行されているのではなく、差し替えを行っても契約が保たれるという可能性のことを指す。
「柔軟性」と呼ぶ範囲には、現在の仕様の上で差し替え可能性が成立している状態と、定義域を広げたときにも契約と I を保てる拡張経路がある状態の両方を含む。前者は現在の仕様の静的判定、後者はケース1からケース2 への移行可能性として現れる。後述するケース2 の留保や DSL 節の問いは後者の話である。
事後条件を強くするとは、プロバイダが自分の自由を削って呼び出し側に約束を増やす操作で、その代わりに呼び出し側のコードが変更に強くなる。先述の Nygard "Constrain the Provider to Liberate Callers" は事前条件の側で実装非依存性を強める操作を述べていた。本ページの「事後条件を強める」は、事後条件の側でプロバイダの自由度を削って呼び出し側の安定性を上げる、対になる操作である。
冒頭で並べた定義群とも矛盾しない。
SDF (additive programming) は「既存コードを修正せずコード追加で適応」と言う。事後条件が強い OrderRepository.findById なら、永続化を DynamoDB に移すときも DynamoOrderRepository を追加するだけで呼び出し側はそのまま
ISO/IEC 25010 の Modifiability は「欠陥導入や既存品質の劣化なく」を要件にしている。事後条件が弱い JSON.parse(s) → any だと、データ構造が変わっても呼び出し側のコードは型エラーにならず、実行時まで破綻が分からない
Fowler の「変更困難でないこと」も同じ話で、事後条件が弱いと依存関係が型から読めず、変更影響を grep で追うことになる
どの定義も、見ているのは呼び出し側を含むシステム全体が変更に耐えるかどうかである。プロバイダ単体の自由度は問うていない。
定義域の広さ (D)
3つ目の軸として定義域の広さを置く。仕様の事前条件 Pre が定める「正しく動作することが約束される入力」の集合の広がりである。
S₂ の事前条件 Pre₂ が S₁ の Pre₁ より弱い ⇔ Pre₁ ⇒ Pre₂
事前条件が弱いほど、より広い入力に対して仕様が定義される。集合の包含関係に対応するので半順序になり、Map<String, Integer> と Map<Integer, String> のように互いに包含関係にない仕様は比較不能である。
定義域の測り方は、型シグネチャに現れる型ではなく、仕様として正しく動作することが約束されている入力の集合で測る。invoke(action: String, args: Object[]) のように型の上では String でも、仕様として受け付けるのが "createOrder" | "cancelOrder" | "refundOrder" の3値であれば、定義域は3値の集合として扱う。逆に findBy(spec: OrderSpec) のように型の上では OrderSpec 1種でも、OrderSpec が組み立て可能な問い合わせの集合が仕様として明示されていれば、その集合のカーディナリティで測る。
3つの軸 (定義域、契約強度、実装非依存性) はそれぞれ独立な半順序をなす。片方を動かしたときにもう一方が同じ方向に動く必然はない。定義域を広げる操作は契約強度や実装非依存性とは独立に行えるが、何もせずに広げれば広げた領域で事後条件が落ちるのが通例で、この独立性は「広げても自動で保たれる」という意味ではない。広げた領域で P と I を保つには追加の設計が要る (後段「汎用性側の手法がしていること」)。
因数分解
Liskov & Wing 1994 の部分型関係は、事前条件の弱化と事後条件の強化の積として与えられる。本ページはこの積を次のように組み換える。(a) 事後条件の強化を契約強度 P として柔軟性側に、事前条件の弱化を定義域の広さ D として汎用性側に分ける。(b) 両者に共通する因子として実装非依存性 I を独立軸に取り出す。新たな部分型関係を定義しているのではなく、既存の単一の積を直交する2つの積 (柔軟性 = P × I、汎用性 = D × I) に組み換えている。 3つの軸を使って、汎用性と柔軟性を直交する2軸の積として書ける。
汎用性 = 定義域の広さ × 仕様の実装非依存性
柔軟性 = 仕様の契約強度 × 仕様の実装非依存性
定義域の広さ(事前条件の弱さ)は汎用性側にだけ、契約強度(事後条件の強さ)は柔軟性側にだけ現れる。実装非依存性は両者に共通する。
掛け算で書いているのは、どちらかの因子がゼロに近づくと積もゼロに近づくことを示す形にしたいためで、定量モデルではない。
汎用性側の式の含意はこうである。定義域が広くても、仕様が特定実装の詳細 (方言・物理スキーマ等) に依存していれば、呼び出し側は実装ごとに使い分けを強いられ、結果として一つの実装が扱える範囲とは言えなくなる。「呼び出し側に実装詳細を持ち出させずに」としたのはこの理由で、汎用性は D だけでは決まらず I が低いと定義上の汎用性が下がる。柔軟性側の式は、強い事後条件と実装非依存性の両方が揃って初めて呼び出し側が安定することを示す。
ケーススタディ
事後条件の強さ P、実装非依存性 I、事前条件の弱さ D の3軸の組み合わせは 2³ = 8 象限あるが、ここでは現場で遭遇しやすい代表的な7ケースを並べる (ケース4, 5 は同じ象限の別パターン)。柔軟性 = P × I、汎用性 = D × I なので、各象限がどう柔軟性・汎用性に効くかが見える。
題材は EC サイトの注文管理ドメインに揃える。同じ業務領域の中で、API の切り方を変えるだけで代表的な象限が現れることを示す。
table:table
# P I D 代表例 柔軟性 汎用性
1 強 高 狭 OrderRepository.findById(id) → Order 高 低
2 強 高 広 OrderRepository.findBy(spec: OrderSpec) → Order[] 高 高
3 強 低 狭 OrderRepository.findByIdWithOracleHints(id, hint) → Order 低 低
4 弱 低 広 OrderQueryService.search(criteria: Map<String, Object>) → Map<String, Object>[] 低 低
5 弱 低 広 OrderDao.executeSql(sql, params) → ResultSet 低 低
6 弱 高 狭 OrderLegacyApi.invoke(action: String, args: Object[]) → Object 低 低
7 弱 高 広 OrderJsonApi.parse(json: String) → any 低 低
ケース1: P強・I高・D狭 — OrderRepository.findById(id) → Order
仕様として受け付ける入力は OrderId だけで、定義域は単一の問い合わせ (ID で一件引く) に限定される。事後条件は「Order 集約の不変条件 (注文行と合計金額が整合する、ステータスが定義済みの値である等) を満たす値が返る、もしくは存在しないことが示される」と強い。仕様は永続化機構に依存しない。
定義域が狭いので汎用性は低い。強い事後条件と実装非依存性が揃っているので、永続化実装を RDB から DynamoDB に替えても、Read Replica からの読み出しに変えても、テスト用の in-memory 実装に差し替えても、呼び出し側は壊れない。この差し替え可能性が契約の上で成立していれば、line 140 の通り実際に差し替えが行われるかどうかとは独立に柔軟性がある。ケース1 は柔軟性が高い場合にあたる。
ケース2: P強・I高・D広 — OrderRepository.findBy(spec: OrderSpec) → Order[]
OrderSpec は「ステータスが Pending」「期間が 2026-01-01 〜 2026-01-31」「合計金額が一定値以上」といった検索条件を組み立てる Specification (Specification パターン)。OrderSpec が組み立て可能な問い合わせの集合が仕様として明示されており、ID 検索一本のケース1 よりその集合が大きいので D は広と扱う。
事後条件は「仕様が受け付けるすべての OrderSpec に対して、Order 集約の不変条件を満たすコレクションが返る」と一様に強く保たれている。OrderSpec の項を追加することで新しい検索ニーズに対応しても、事後条件 (Order 集約の不変条件) はそのまま保てる。実装非依存性も維持される (RDB の WHERE 句に翻訳しても、Elasticsearch のクエリに翻訳しても良い)。
定義域を広げつつ事後条件と実装非依存性を保ったので、汎用性と柔軟性が同時に高くなる。設計の理想形だが、OrderSpec で表現できない条件 (例: 「商品名に特定のキーワードが含まれる」を急いで足したくなる) が出てきたときに事後条件を保ったまま拡張できるかどうかが分かれ目になる。表現できないなら素直にケース1に留める方が安全である。
ケース3: P強・I低・D狭 — OrderRepository.findByIdWithOracleHints(id, hint) → Order
ケース1と同じ Order 集約を返すが、引数に Oracle 固有のオプティマイザヒント (/*+ INDEX(orders idx_order_id) */ の選択を制御する文字列など) を取る。事後条件は強く定義域も狭いが、hint パラメータの存在によって仕様が Oracle のクエリプランナの挙動に依存する。
呼び出し側はその場では Order 型を受け取る限り壊れない。しかし永続化基盤を PostgreSQL や DynamoDB に変える段階で、hint をどう扱うかの判断を呼び出し側も迫られる。事後条件だけ強くしても、実装非依存性を欠いていれば差し替えできない形にあたる。永続化技術の都合がドメイン層の API に現れている形である。
ケース4: P弱・I低・D広 — OrderQueryService.search(criteria: Map<String, Object>) → Map<String, Object>[]
検索条件を Map<String, Object> で受け取り、検索結果を Map<String, Object>[] で返す汎用検索 API。仕様として受け付けるキー集合の上限が決められておらず、任意のキーと値の組み合わせが仕様の定義域に含まれるので D は広。
Map の容器自体の実装 (HashMap か TreeMap か) は差し替え可能だが、本ページの I は line 101 の通り「仕様が実装詳細に漏れない度合い」を指す。このケースでは事後条件が「Map のリストが返る」止まりで、どんなキーが入っているか、それが Order の何を表しているかを仕様が約束していない。呼び出し側は result.get("order_id") のようにキーを直接書いて値を取り出し、自分で型キャストする。キー名の規約や値の解釈という仕様外の知識が呼び出し側に漏れており、I は低い。
Order 集約の構造が変わると、検索 API 側のコードは無傷でも呼び出し側のキー文字列が壊れる。定義域を広げても、事後条件が弱く実装非依存性も低いので柔軟性は得られない。汎用性側の式 D × I の I が低い分、汎用性も下がる。
ケース5: P弱・I低・D広 — OrderDao.executeSql(sql, params) → ResultSet
任意の SQL 文を受け取り ResultSet を返す。仕様として受け付ける入力が任意の SQL 文なので D は広。事後条件は弱い (ResultSet が返るところまで、カラム構造は入力次第)。さらに、SQL 文自体が RDBMS の方言に依存する (Oracle の ROWNUM は PostgreSQL では動かない) ため、仕様が特定の RDBMS 実装の知識を要求する。実装非依存性を満たさない。
ケース4 と同じく P 弱・I 低・D 広の象限だが、I の漏れ方の程度が違う。ケース4 はキー解釈という単一アプリ内の規約漏れに留まるのに対し、ケース5 は永続化基盤ごとに異なる SQL 方言まで呼び出し側が知る必要がある。「Order 関連は全部この DAO で書く」というルールにしておきながら、実態としては呼び出し側が永続化技術と SQL 方言の知識を全部持つことになる。
ケース6: P弱・I高・D狭 — OrderLegacyApi.invoke(action: String, args: Object[) → Object]
action 文字列で操作を指定し、引数配列を渡し、結果を Object で受け取る。古い RPC スタイルの API や、リフレクション越しのファサードに見られる形。
action が仕様として受け付ける文字列は有限の列挙に限定されている (例: "createOrder", "cancelOrder", "refundOrder" の3つだけ)。型シグネチャ上は String 任意だが、仕様としての定義域は3値に限られるので D は狭と扱う。実装非依存性はある (内部実装を差し替えても、action ごとに同じ結果が返る)。しかし戻り値が Object で、呼び出し側がキャストして解釈する。事後条件は弱い。
仕様としての定義域も狭く事後条件も弱いので、汎用性も柔軟性もない。型システムから見ると最も広い面を持っているのに、実用上は最も縛られている、という反転が起きる象限である。
ケース7: P弱・I高・D広 — OrderJsonApi.parse(json: String) → any
任意の JSON 文字列を受け取り any を返す。仕様として受け付けるのは ECMA-404 に従う任意の JSON 値なので D は広。パーサ実装を差し替えても結果は ECMA-404 に従って一意なので、仕様は特定パーサの実装詳細に依存しない (I 高)。しかし事後条件は「何らかの JSON 値が返る」止まりで、それが Order を表すのか、どのフィールドを持つのかは約束していない (P 弱)。
呼び出し側は返ってきた値の構造を自前で解釈する必要があり、Order の構造が変わると解釈コードが壊れる。I が高くても P が弱いと柔軟性は得られない、ということを示す象限である。汎用性側の式 D × I は成立しているように見えるが、line 32 の汎用性の定義「呼び出し側に実装詳細を持ち出させずに扱える入力・文脈の範囲の広さ」に照らすと、呼び出し側が JSON 構造の解釈という仕様外の知識を持ち出しているので、実効的な汎用性も低い。line 107 で挙げた JSON.parse と同じ構造である。
検算と整理
3軸を独立に動かして象限を見ると、柔軟性が高くなるのは事後条件と実装非依存性が両方とも高い場合 (ケース1, 2) に限られる。事前条件の広狭はそれを汎用性につなげるかどうかを決めるだけで、柔軟性そのものを生まない。
事後条件が弱いと柔軟性は得られない (ケース4, 5, 6)
実装非依存性が低いと、強い事後条件があっても差し替えできない (ケース3)
事後条件と実装非依存性が揃った上で定義域を広げられれば、汎用性と柔軟性が同時に高くなる (ケース2)。ただしこれは広げた定義域全体で事後条件が一様に保てる場合に限られ、保てないなら定義域を広げずケース1に留める方が安全である
「現時点で要求されていない領域まで定義域を広げる」が柔軟性として機能するのは、ケース1からケース2への移行ができる場合だけである。事前条件だけ弱めて事後条件・実装非依存性の裏付けがなければ、ケース4 (うまくいけば) かケース5 (実態としてしばしば) になる。これが Speculative Generality と柔軟性の境界に対応する。
必要以上の汎用性と Speculative Generality の分かれ目
冒頭で予告した命題「現時点で要求されていない領域まで定義域を広げ、その広げた領域でも仕様の約束が保たれていれば、それは柔軟性として機能する」を、ここまでの3軸を使って書き直せる。俗に「必要以上の汎用性があると柔軟性が高い」と言われるものの正確な内容がこれにあたる。ここでの「仕様の約束が保たれている」は、事後条件 (契約強度) と実装非依存性が広げた領域全体で成立することを指す (ケース2 に相当)。
事前条件を弱めるだけで事後条件・実装非依存性の裏付けがないと、柔軟性ではなくSpeculative Generality (後述) になる。字面の上では「広い型」「抽象クラス」「設定項目の追加」と見分けがつかない。判別のための問いは2つある。
(1) 広げた定義域の全要素について、呼び出し側が具体実装を詮索せずに済むか (実装非依存性)
(2) 広げた定義域の全要素について、呼び出し側が必要とする意味を事後条件が十分に約束できるか (契約強度)
両方が書ければ柔軟性として機能する。どちらかが書けないなら、具体ケースごとの分岐や構造解釈を呼び出し側が担うことになり、Speculative Generality になる。
なぜ柔軟性が必要か
変更は止まらない
放置すれば複雑性は自動で増える
Lehman の第2則 (Increasing Complexity): 明示的な対抗作業 (リファクタリング等) がなければ複雑性は増大し続ける。複雑性の増大は変更吸収のコストを押し上げるので、柔軟性を保つには複雑性の増大を継続的に抑える作業が必要になる。Cunningham の Technical Debt (Fowler の再解釈) は、対抗作業を怠った場合のコストの呼び名である。 ただしこれは「予測して作り込めばよい」という話ではない。Fowler の "Yagni" は実装予定だった機能の約3分の2は価値を生まないと観察している。前もって作り込んだ柔軟性は、当たらなかった予測の分だけ負債になる。Beck & Matts らの Real Options (InfoQ記事) はこの折り合いの立て方で、決定を遅らせて情報が増えてから選ぶ、という見方を提示している。後段の「柔軟性のコスト」もここに繋がる。 汎用性のレベル
スキーマ設計が要求される場面では、どれだけ広いデータ形式を受け入れる必要があるかで適した戦略が変わる。前段の3軸でいえば、事前条件をどこまで弱めるか (定義域をどこまで広げるか) の段階に対応する。段の取り方は、定義域を決める主体 (設計者・ユーザ・無制約) が切り替わる地点を基準にする。以下はデータスキーマに関する類型(kawasimaのツイート2024-11-15 で提示した整理)。 table:table
レベル 事前条件 (定義域) 例 適した戦略
1 事前定義された数種類のみ 注文ステータスが数種類 通常のリレーショナルモデル
2 事前定義だが種類が非常に多い 商品カテゴリごとに変わる属性 (カカクコム的なもの) 後述の「データ定義域を広めにとる」
3 ユーザが実行時に定義 RedmineのIssueのカスタムフィールド EAV、カスタムフィールド機構、メタデータ駆動のUI生成
レベル1〜3 は事前条件 (仕様として受け付ける入力集合) が段階的に弱まる範囲にあり、前段で定義した定義域の半順序の上で比較できる。
この3段には乗らない別枠として、スキーマ自体を仕様として書くことを放棄するケースがある。ログのように「任意の構造を受け付ける」とだけ決まっている場合で、ドキュメントDB、JSONカラム、ログストアといった戦略が対応する。これは定義域の半順序上の最大元ではなく、軸そのものから降りたケースとして扱う。以降の議論はレベル1〜3 を対象にする。
レベルが上がるごとに事前条件が弱まる。ただし事前条件を弱めるだけでは柔軟性にはならず、広げた領域全体で事後条件と実装非依存性を保つ必要がある。前段の Speculative Generality の分かれ目と同じ判定がここにも当てはまる。たとえばレベル3を狙ってEAVだけ入れて事後条件 (どの属性がどの型でなければならないか) を保たない設計は、ケース4 (Map<String, Object> 的) になる。
『SQLアンチパターン』第2版の EAV章は、この分類で見ればレベル1のケースしか検討していない。つまり「事前定義された数種類のみ」の入力に対して EAV を使うべきか、という問いに答えている。しかし EAV を採用せざるを得ないのはレベル3前後 (ユーザが実行時に属性を定義する) であり、レベル1での当否を論じても、実際に EAV が選ばれる場面の議論にはならない。
事後条件のレベル
汎用性のレベルが事前条件の弱さの段階だったのと対称に、事後条件の強さも段階で整理できる。段の取り方は、呼び出し側が型システムから得られる情報量が質的に変わる地点を基準にする。レベル間は半順序上の含意関係ではなく、呼び出し側が依存するコードパターンの違いで段を取っており、異なるレベルの契約は必ずしも半順序上で比較可能ではない。柔軟性は事後条件と実装非依存性の積なので、このレベルは柔軟性の一因子のレベルであり、そのまま柔軟性のレベルではない。同じ集約に対する API の切り方を例に並べる。
table:table
レベル 事後条件の強さ 例 (Order ドメイン)
1 副作用のみ・順序や前提条件が暗黙 戻り値なしのイベントハンドラ、グローバル状態への書き込み
2 戻り値の型は決まるが要素構造は呼び出し側が解釈 executeSql(sql) → ResultSet
3 戻り値の構造も型で与えられるが不変条件が保証されない findById(id) → User (不変条件未検証のデータクラス)
4 戻り値の型・不変条件・例外条件が明確 OrderRepository.findById(id) → Order | None
レベル2 は、executeSql のようにカラム構造が SQL 次第のものと、search(...) → Map<String, Object>[] のようにキー集合が仕様化されないものを含む。どちらも呼び出し側が要素の構造を文字列キーや列名で解釈する点で事後条件の強さは同じで、差は実装非依存性 I の側に出る。
レベルが上がるごとに事後条件が強くなり、呼び出し側の安定性に寄与する。ただし柔軟性のためにはもう一方の因子である実装非依存性も同時に必要になる。事後条件レベル4相当の API であっても、内部実装が特定永続化基盤に依存していれば差し替えはできない。前段ケース3の findByIdWithOracleHints は、戻り値の Post が強い一方で Pre 側に特定実装の知識を含む例にあたる。
柔軟性・汎用性を達成する設計
柔軟性 = P × I、汎用性 = D × I という因数分解に戻ると、設計手法は P・I・D のどれかを上げる作業として整理できる。ここでは手法ごとに、どの軸に効くか、結果として柔軟性側か汎用性側 (あるいは両方) に寄与するかを表にまとめる。
table:table
手法 効く軸 寄与先 補足
ドメイン型 (型による制約) P 柔軟性 プリミティブ型では言えない不変条件を型で約束する
Repository / Aggregate P + I 柔軟性 エンティティの不変条件を契約として与え、永続化依存を消す
依存性逆転 (DIP) I 柔軟性 呼び出し側がインターフェースに依存し、実装を差し込める
OCP (多態性ベース) I 柔軟性 インターフェースを閉じたまま実装を足せるように拡張軸を事前に切る
Parnas 情報隠蔽 I 柔軟性 変更されうる決定を境界の裏に隠す
Hexagonal (Ports & Adapters) I 柔軟性 ポートで実装を差し替え可能にする
Seam I 柔軟性 既存コードに後付けで非依存性を取り戻す
データ定義域を広めにとる D + (P・I の維持) 汎用性 広げた領域で P と I が保てれば汎用性が上がる
代数的データ型・パラメトリック多相 D + I 汎用性 型で定義域を明示し、実装を定義域に依存させない
SDF Combinators P 柔軟性 標準インターフェースで事後条件を組み立てる
SDF Generic dispatch I 柔軟性 型ごとの実装を後追加する
SDF Layering / Propagation P 柔軟性 注釈・部分情報を契約として明示する
Kleppmann スキーマ進化 P (時間軸) 柔軟性 Forward/Backward 互換性は事後条件を時間軸に拡張したもの
イベントソーシング / CQRS I (派生独立) 柔軟性 事実の記録と派生計算を分離し、読みモデル側の I を確保する
Fitness Functions (測定) 両方 3軸の劣化を継続観測する仕組み
表の見方の注意点を2つ。
寄与先の柔軟性・汎用性は排他的ではない。Repository / Aggregate は柔軟性側だが、集約境界に検索用の Specification を足せば汎用性側にも寄与しうる (前段ケース2)。表の寄与先はその手法の中心的な射程を示す。
P と I は軸として独立だが、手法が両方を同時に上げうることは別の話である。I を上げる構造 (境界・インターフェース化) は事後条件を書く場所を作るが、そこに何を書くかは別問題で、インターフェースが空なら P は上がらない。表で I-only と分類した手法は、契約を書く場所がすでにある前提で、その場所の実装非依存性を確保する役回りにあたる。
柔軟性側の手法がしていること
柔軟性側の手法は、呼び出し側への約束 (P) を強めるか、仕様を実装詳細から切り離す (I) か、その両方を行う。代表的な形は以下。
型で不変条件を約束する: ドメイン型、代数的データ型、Repository / Aggregate。プリミティブ型や動的型では表現できない事後条件を、型の生成時に検証を強制することで呼び出し側に渡す
境界で実装を差し替え可能にする: DIP、OCP、Hexagonal、Parnas、Seam、SDF Generic dispatch。呼び出し側が抽象インターフェースに依存することで、裏の実装を取り換えても契約が保たれる
時間軸に契約を延ばす: Kleppmann のスキーマ進化 (Forward/Backward 互換性)、イベントソーシング (事実と派生の分離)。契約を現在の仕様だけでなく過去・未来の版とも両立させる
汎用性側の手法がしていること
汎用性側の手法は、定義域 D を広げたうえで、広げた領域全体で P と I を保つ。汎用性の式には D と I しか現れないが、P を同時に保つことには二重の意味がある。第一に、I を維持するには P の定義自体が実装詳細に依存しない形で書けていることが必要なので、P の保持は I を落とさないための前提になる。第二に、P が保てれば同じ拡張が柔軟性側にも効く。前段の「必要以上の汎用性と Speculative Generality の分かれ目」で立てた2つの問い (広げた領域全体で I と P が保てるか) に両方答えられれば、汎用性と柔軟性が同時に上がる。答えられないなら Speculative Generality になる。
代表的な手法は以下。
代数的データ型・パラメトリック多相: 支払い手段の種類 (CreditCard | BankTransfer | Cash) が増えても、合計金額の計算関数が修正不要であるように、定義域を閉じた union や型パラメータで表現し、実装を定義域に依存させない。広げた領域全体で P と I を保てる形にあたる
データ定義域を広めにとる: Order 型に discounts: Discount[] を持たせ、将来の割引種類の追加に対しても同じ合計金額計算で扱えるようにする設計。定義域を広げる前提として、広げた領域で P が一様に書けること、その P が種類によって分岐しないこと (I の維持) が要る
Specification パターン: 検索条件を閉じた ADT として組み立て可能にする。検索の定義域を広げつつ、どの条件でも事後条件 (Order 集約の不変条件) を保つ。前段ケース2 の構造
いずれも「型で定義域を表現し、実装がその定義域の各要素に個別に依存しない」という点が共通で、これが Speculative Generality との分かれ目になる。
観測する手法
柔軟性とされることもあるが直結しないもの
柔軟な設計とされがちだが、ここでの定義 (既存の振る舞いを壊さず、妥当なコストで変更を吸収できる度合い) を満たさないものを、前段の3軸 P / I / D と6ケースに対応づけて並べる。多くは「定義域 D を広げただけで、広げた領域で事後条件 P と実装非依存性 I を保っていない」という共通のパターンで、前段のケース4〜5に対応する。
テーブルに予備カラムを持たせておく
使い道未定のカラムを先に追加しておく設計。仕様上、このカラムが何を意味するかは決めていないので、事後条件 P が約束できない状態で定義域 D だけ広げたことになる。後から予備カラムに意味を与える段階では、カラム解釈のコードを呼び出し側にも書き足す必要があり、既存コードを修正せずに変更を吸収する (additive programming) という柔軟性の条件を満たさない。ケース4 (P弱・I低・D広) に該当する。
柔軟性の観点とは別に、ALTER TABLE ADD COLUMN の実行コスト (運用手続きや停止時間の確保を含む) が高い環境で、DDL 変更を前倒ししておくという運用上の意味はある。これは柔軟性そのものではなく、スキーマ変更コストの時間的分散として扱う。
汎用マスタ
1 つのテーブルに複数種類のマスタデータを入れ、種別カラムで分岐する設計。前段の汎用性レベル分類でいえばレベル3 (ユーザ定義に近い水準) を狙う形になるが、種別ごとの型・制約・参照整合性はテーブル定義では表現できず、アプリケーション側が種別ごとの解釈を持つことになる。事後条件 P が種別ごとに分かれ、全種別で一様な契約を呼び出し側に与えられない。ケース4 に近い構造である。
メッセージに予備フィールドを持たせておく
予備カラムと同じ構造で、メッセージスキーマの側に未定義のフィールドを残す設計。D を先に広げるが、予備フィールドの事後条件 P は決まっていない。後から意味を与える段階で、送信側と受信側の両方に解釈コードを入れる必要があり、既存コードの修正なしに機能追加はできない。
ただし固定長バイナリメッセージのように、あらゆるフィールド追加が破壊的変更となる場合に、後方互換のための領域を先に確保しておく、という限定的な意味はある。この場合も柔軟性ではなく、互換性維持のための前払いである。
DSL / ワークフローエンジン
特定ドメインの記述を DSL に切り出すと柔軟になる、と語られることがある。しかし DSL の導入自体は柔軟性を与えない。DSL は別のプログラミング言語の追加であり、記述・修正・テスト・学習のコストはホスト言語と同様に発生する。
DSL が柔軟性に寄与するのは、前段の2つの問いを DSL の評価器が満たす場合に限られる。
(1) DSL が生成しうる挙動の全域について、呼び出し側が評価器の具体実装を詮索せずに済むか (I)
(2) DSL が生成しうる挙動の全域について、呼び出し側が必要とする意味を事後条件として約束できるか (P)
両方が満たせていれば柔軟性として機能する。どちらかが満たせない状態で DSL を導入すると、ホスト言語と DSL の両方を保守することになり、変更コストは素直にホスト言語で書いた場合より増える。
処理フローをテーブルに入れる
処理順や条件分岐をテーブル駆動にする、いわゆるルールエンジン的な設計。テーブルの各行がどの共通処理をどの順で呼ぶかに制約があり、その制約がテーブル定義ではなくアプリケーション側の暗黙のルールとして残っていると、テーブルが取りうる組み合わせのうち正しく動くのは一部に限られる。定義域 D は広いが、全域で事後条件 P を保てない構造で、ケース4 の派生である。
決定表のように、テーブルの各セルの意味と組み合わせの意味が閉じた集合として仕様化できている場合はこの限りでない。この場合は前段のケース2 (D広・P強・I高) に近づき、本節の対象からは外れる。
柔軟性ではない軸でのメリットについて
予備カラム、予備フィールド、固定長メッセージの互換領域、いずれも柔軟性そのものではなく、DDL 変更や互換性維持のコストを時間軸で前払いするという別軸の意味を持つ。柔軟性の議論と混ぜると評価を誤るので、柔軟性とは別軸の運用上の判断として分けて扱う。
柔軟性を阻害する設計
前節は「柔軟性と言われるが柔軟性ではない」設計を並べた。ここでは、積極的に柔軟性を下げる方向の設計を挙げる。
組合せ可能性の低いモジュール設計
モジュール間に時間的結合がある。「A → B → C」の順に必ず呼ばなくてはならない、のような暗黙の順序依存。呼び出し側は順序を別途把握する必要があり、モジュール単体の事後条件 P では状態遷移を表現できない
モジュールを呼べる条件がデータベースの状態に依存している。結合強度の高い外部結合かつ暗黙的。呼び出し側は DB 状態を別経路で知る必要があり、暗黙の事前条件が仕様外に存在する いずれも、モジュール単体の仕様を読んでも呼び出し側が必要な情報を得られず、外部の状態や順序への依存が呼び出し側に漏れる。暗黙の事前条件と順序依存で、呼び出し側への仕様が仕様外の情報に頼る構造である。
柔軟性のコスト
前節「柔軟性とされることもあるが直結しないもの」は、柔軟性と呼ばれるが柔軟性ではない設計を扱った。本節は、柔軟性を正しく追求しても発生するコストと、追求しすぎて柔軟性自体を損なうパターンを扱う。Speculative Generality と EAV は両節に現れるが、前節では誤認される構造として、本節では追求の副作用としてのコストとして扱う。
柔軟性はタダではない。P (契約強度) を現在の要求より先回りして強く書きすぎると、求めていたはずの柔軟性自体を損なう。I (実装非依存性) は Composability の前提として比較的無条件に有効だが、P の前払いにはコストが乗る。以下は代表的な落とし穴である。
Speculative Generality
Fowler 『Refactoring』 の匂い(smell)の一つ。"Oh I think we need the ability to do this kind of thing someday" でフック、特殊ケース、抽象クラスを足していくパターン。Fowler の挙げる4つのコスト: Build: 不要機能の分析・実装・テストに時間を消費する
Delay: 緊急機能を圧迫する
Carry: 追加の複雑性がすべての後続作業を遅らせる
Repair: 実際の要件が仮説と異なれば作り直しになる
見分け方: その関数やクラスを使っているのがテストケースだけなら、消していい。
Over-Engineering の逆説
過度な抽象化・汎用化・層状化は、柔軟性に見えて複雑性を増やし、結果として変更しづらくする(LeadDevの整理など)。柔軟性は手段であって目的ではない。 EAV のコスト
前節の「汎用マスタ」と分類上は重なるが、本節では採用した場合のコスト側を扱う。Karwin 『SQL Antipatterns』 の EAV 章が挙げる代償。属性を行ごとに分解しメタデータとデータを混在させると、型制約の強制、必須属性の検証、参照整合性、クエリ表現がすべて複雑になる。 レベル分類と合わせると、EAVを入れる前にレベルを確定することで、必要な複雑性と不要な複雑性を切り分けられる。
YAGNI の条件付き適用
Yagni only applies to capabilities built into the software to support a presumptive feature, it does not apply to effort to make the software easier to modify.
つまり、投機的な機能追加にはYAGNIを適用するが、コードを変更しやすく保つ努力にはYAGNIを適用しない。「必要以上の汎用性とSpeculative Generalityの分かれ目」の2つの問いに当てはめれば、事後条件と実装非依存性の裏付けがある投資はYAGNIの適用外、それがない事前条件だけの拡張はYAGNIの適用対象になる。
柔軟性と単純性のトレードオフ
Hickey "Simple Made Easy" (2011) は、Simple (one fold、絡まっていない) と Easy (手近) を区別する。柔軟性を「何でも差し替えられる」方向に追いすぎると、要素を complect (絡み合わせる) ことになり単純性を失う。柔軟性が高いように見えて、実際には変更しづらいシステムができる。 参考文献