複雑さの分類
本質的か偶有的か?
『人月の神話』の中のエピソード『銀の弾丸はない』で書かれた。銀の弾丸はないでは、複雑さだけに言及したものではないが、後続研究『Out of the Tar Pit』では、複雑さが最も重要だとして、本質的複雑さと偶有的複雑さに分類して言及している。 偶有的複雑さ
Out of the Tar Pitでは
導出可能な状態
コントロール
が偶有的複雑さの要素として挙げられている。
導出可能な状態
例えば、以下のような銀行口座とその口座への入出金を表す取引で構成されるモデルを考える。
https://gyazo.com/37917b7bcad62afb2a1fbba22b965680
取引を集計しさえすれば、現在の残高は求まる。
code: sql
SELECT SUM(金額) FROM 取引 WHERE 口座番号 =?
つまり本質的に必要な情報は取引のみであり、残高はそこから導出できる値である。
しかし実際のシステムでは、毎回すべての取引履歴を集計すると計算量が大きくなるため、口座に残高を持たせるのが普通である。
https://gyazo.com/496c57dc5637abb6cd6a0ca6f3780754
これは性能上の理由としては自然であるが、導出可能な値を状態として持った瞬間に、追加の管理責任が発生する。
例えば、入金取引を登録する処理を考える。
code:java
transactionRepository.save(new Transaction(accountId, 1000));
本来、取引履歴だけを正とするなら、この処理だけでよい。しかし口座に残高も持たせている場合、残高も更新しなければならない。
code:java
transactionRepository.save(new Transaction(accountId, 1000));
Account account = accountRepository.findById(accountId);
account.deposit(1000);
accountRepository.save(account);
このとき、取引履歴と残高は同じ事実を二重に表している。
取引履歴に1000円の入金がある
口座残高が1000円増えている
この2つは常に一致していなければならない。
もし後から返金処理を追加した開発者が、取引履歴だけを登録して残高の更新を忘れると、不整合が発生する。
code:java
transactionRepository.save(new Transaction(accountId, -500));
// account.balance の更新を忘れている
この場合、取引履歴を集計した残高と、口座に保持されている残高がずれる。
取引履歴から計算した残高: 1500円
口座に保存された残高: 2000円
ここで発生している不具合は、銀行業務そのものの複雑さではない。「残高」という導出可能な値を、性能上の都合で状態として保持したために発生している。
さらに、同時に複数の取引が発生する場合は排他制御も必要になる。
code:java
Account account = accountRepository.findById(accountId);
account.deposit(1000);
accountRepository.save(account);
この処理が同時に2回実行されると、両方の処理が同じ残高を読み取り、片方の更新が失われる可能性がある。
このように、導出可能な状態を持つと、
更新漏れがないか
同時更新で不整合が起きないか
取引履歴と残高がずれていないか
ずれた場合にどう修復するか
そもそもずれをどう検知するか
を考えなければならなくなる。
導出可能な状態による偶有的複雑さは、キャッシュや集計値を持つこと自体が悪いという話ではない。
問題は、本来は計算できる値を状態として保持することで、その値を正しく同期し続ける責任が発生することである。
コントロール
コントロールによる偶有的複雑さとは、本質的には順序と無関係なルールが、プログラムの書き方によって順序に依存してしまうことで生じる複雑さである。
例えば、飲食店の割引ルールを考える。
会員割引: 5%
誕生日割引: 10%
雨の日割引: 15%
このとき適用する割引は、「条件を満たす割引のうち、最大の割引率のもの」である。
つまり本質的なルールは、
適用可能な割引を集める
その中から最大割引率を選ぶ
である。
ここに「会員割引を先に判定する」「雨の日割引を後に判定する」といった順序の概念は存在しない。
しかし、手続き的に次のように書くと、暗黙的に順序が意味を持ってしまう。
code:java
int discountRate = 0;
if (customer.isMember()) {
discountRate = 5;
}
if (customer.isBirthday()) {
discountRate = 10;
}
if (weather.isRainy()) {
discountRate = 15;
}
このコードが正しく動く。しかしそれは、割引率が低いものから高いものへ並んでいるからである。
つまり、このコードの正しさは業務ルールだけではなく、コードの記述順序にも依存している。
後から「ゴールド会員割引 20%」を追加する場合、開発者は次のように書かなければならない。
code:java
if (customer.isGoldMember()) {
discountRate = 20;
}
ただし、このコードはどこに書いてもよいわけではない。20%の割引なので、15%の割引よりも後ろに書く必要がある。
もし誤って次のように書くと、不具合になる。
code:java
int discountRate = 0;
if (customer.isMember()) {
discountRate = 5;
}
if (customer.isGoldMember()) {
discountRate = 20;
}
if (customer.isBirthday()) {
discountRate = 10;
}
if (weather.isRainy()) {
discountRate = 15;
}
この場合、ゴールド会員で雨の日でも、最終的な割引率は20%ではなく15%になる。
なぜなら、後から実行された weather.isRainy() の判定が discountRate を上書きしてしまうからである。
ここで発生している不具合は、割引ルールそのものの複雑さではない。
「最大割引率を選ぶ」という本来順序と無関係なルールを、変数への代入の積み重ねとして表現したために、コードの並び順を開発者が注意して管理しなければならなくなっている。
これはプログラムの書き方によって発生した偶有的複雑さである。順序による偶有的複雑さを持ち込まないようにするには、以下のように書くと良い
code:java
int discountRate = Stream.of(
customer.isMember() ? 5 : 0,
customer.isBirthday() ? 10 : 0,
weather.isRainy() ? 15 : 0,
customer.isGoldMember() ? 20 : 0
)
.max(Integer::compareTo)
.orElse(0);
適用可能な割引率の候補を列挙し、その最大値を選ぶからである。
つまり、 条件を満たす割引候補の中から割引率の最も高いものを選ぶという業務ルールが、そのままコードに現れている。
コントロールによる偶有的複雑さは、if文があること自体ではない。問題は、本質的には順序を持たないルールに対して、実行順序という余計な制約を持ち込んでしまうことである。
その結果、開発者は業務ルールだけでなく、
この処理はどこに書くべきか
この順序を変えると結果が変わらないか
より高い割引率の処理が後ろにあるか
まで考えなければならなくなる。
本来不要な順序の管理が発生しているという点で、これは偶有的複雑さである。
/icons/hr.icon
その他、技術的制約がもととなり意思決定にトレードオフが発生するものは、たいていの場合、偶有的複雑さとの引き換え構造がある。
table:偶有的複雑さトレードオフ
手法 導入される偶有的複雑さ 得られるもの
キャッシュ 同期・失効・整合性管理 性能
非正規化 重複データの整合性管理 検索性能
CQRS モデル二重化・同期 読み取り性能・責務分離
メッセージキュー 遅延・再送・順序保証 疎結合・可用性
マイクロサービス ネットワーク障害・分散デバッグ 組織スケール
Feature Toggle 状態空間爆発 安全なリリース
`