「壊れていない」は状態「壊させない」は設計
ソフトウェア開発において設計とはなんでしょうか?
コードが動いていれば、それで十分だと思われるかもしれません。
最近ではコード生成AIも台頭し、設計なんてAIに任せてしまえば?という声もあるでしょう。ですが、それでも最後に何をどう作るかを決めるのは人間です。設計とは判断と思考/思想の軸を明示し、コードに刻み込む行為です。AIにはその思想を持つことができません。
ここでは「壊れていない」コードと「壊させない」コードに分けて見ていきます。
関連記事一覧
- 壊させない設計(予定)
- 型や制約/契約に語らせる(予定)
- 実装編(予定)
- 狂気としての設計(予定)
「壊れていない」コードは単なる状態
今動いているから大丈夫と思えるコードでも、それはたまたま現在壊れていないという状態に過ぎません。コードが将来にわたり問題なく動き続ける保証には何もなりません。未来の修正に時間がかかったり、障害が発生する大きな原因の一つは不十分な設計です。
ここでいう動くとは今この瞬間に動くだけではありません。仕様変更にも運用の揺れにも耐え、明日も動き続けること、それを含めて動くと私は定義します。
何故コードは壊れ、修正ができなくなったり障害が発生してしまうのでしょうか?典型的な理由を整理してみます。
予期しない入力や状態の発生
想定外のデータや状態により、コードが不整合な処理を行ってしまうことがあります。
複雑な条件分岐や状態管理
ロジックが複雑になりすぎると開発者が意図しない組み合わせの状態で不具合が発生します。例えばフラグの乱立は危険です。3つ4つとフラグが組み合わさるとテストケースが爆発的に増えてテストコードのメンテナンスがしにくくなりますし、テストケース漏れが発生するかもしれません。例えカバレッジで満たしていても仮にC0ならば組み合わせが複雑な場合不十分になることがあります。それにより壊れやすくなります。
責務の曖昧さ
そもそも何の責務を持っているかを定義しないと、何を実現しているコードなのかがわかりません。責務が裏に隠されています。そして責務が複数あり、結局何がしたいんだっけ?となることが多いです。例えばControllerがDBアクセスまで持つなどがあります。これによりコードの修正やそれによる影響範囲の特定が困難になり、修正ミスが発生し、壊れやすくなります。
モジュール間の結合が強すぎる
グローバル変数やオブジェクトの内部データが無防備に晒されていると、別の箇所から不整合な値を入れられ不具合が発生するかもしれません。また、どのように振る舞うかの情報隠蔽が出来ていない場合、内部の実装に依存しやすくなり、結果としてモジュール間の結合も強くなります。そして変更や予期せぬ操作で壊れやすくなります。
このように、今壊れていないコードは、裏を返せばまだ壊れる条件に遭遇していないだけかもしれません。現在は正常に動作していても、状態が少し変われば途端に破綻する可能性があります。
この図は、構造が状態のどこまでを責任として持ち、どこからが破綻なのかを可視化したものです。
エラーを結果ではなく、構造との関係で分類しています。構造がその状態を守れたのか?守れなかったのか?その境界を表しています。
InputRejected -> Terminated
は構造によって意図的に拒否された状態です。拒否されたこと自体が構造によって守られています。
Active -> StateError -> PanicTerminated
は状態が構造の範囲を超えて崩壊したルートであり構造が破られています。
構造とは、どこから来て、どこへ向かうかを決める設計の地図です。状態は、その地図に従うか、あるいは逸脱するかを問う現実の振る舞いです。構造が緩ければ、状態は簡単にその網をすり抜け、破綻を引き起こします。今壊れていないのは、ただ壊れる条件にまだ出会っていないだけかもしれません。守られているか?それとも、ただ運が良いだけなのか?それを決めるのが構造であり、設計なのです。
「壊させない」コードは設計で作る
では、どうすれば壊れないではなく、壊させないコードを実現できるのでしょうか。それには設計の力が必要です。ただ動くものを急いで作るだけのプログラミングでは、後々複雑性が増して運用保守が不能になります。
一方、将来を見据えた戦略的プログラミング、すなわち堅牢性を意識した設計こそが長期的に信頼できるシステムを育てます。実際、設計の質が低いシステムは障害発生が多くなりがちであり、信頼性の高いシステムほど設計段階で周到に考慮されているものです。ただし、設計は常に完全ではなく、ビジネス要求によって変わっていきます。つまり変化に耐えられるか?という観点も重要です。
壊させないコードを実現するためのポイントをいくつか挙げます。やり方については別記事に書く予定です。また、思想や好み、使用技術、ビジネス要求によって変わりうる、再現性が必ずあるものではありませんので、それだけはご注意ください。
情報隠蔽とモジュール化
モジュール内部の詳細を隠し、安易に見せないようにします。振る舞いだけが見える、極端に言えば入力がこうなら出力がこうなるという振る舞いだけを見せて内部のやり方をみせないコンポーネントは、依存関係が少なく済みます。また、モジュール間の相互依存を減らすことでシステム全体の複雑さを抑え、結果的に堅牢さが向上します。そのための制約/契約をかけます。
適切なバリデーション
関数やクラスが扱う入力の範囲を明確に定め、範囲外の値や不正な形式のデータは早期に弾きます。例えば引数やユーザ入力を受け取る際にはバリデーションを行い、異常なデータが入り込まないようにします。これによりデータの不整合による処理の誤動作を防止します。当たり前のことではありますが、これ自体を構造による制約/契約で防ぎます。
状態管理を簡素化
コードが扱う状態の数や組み合わせをなるべくシンプル(Easyとは違う)に保つことも重要です。例えば複数のフラグで状態管理をするのではなく、列挙型などで状態自体を一元管理できれば、条件の組み合わせ爆発を防ぐことができます。
実際、状態を示すフラグを乱立させるより、意味を持った単一の状態値で管理した方が意図も明確になり、コードが意図しない組み合わせで壊れるリスクを下げられます。
現場においてそんな単純な話には中々なりませんが、根本は状態によって意味や責務が変わるような構造は壊れやすいため制約/契約で防ぎます。
エラー耐性をつける
設計段階でどう壊れるかを洗い出し、対処法を組み込んでおくことも信頼性向上につながります。例えば例外処理やタイムアウト処理を適切に設けて失敗を受け止めるなどです。ただし、これもできればルールとしてではなく制約/契約で防ぎます。例:型での制約/契約であればResult/Either(別記事予定)など
あくまで例ではありますがこのようなことが実践されていると、コードはたまたま壊れていない状態から、そう簡単には壊れない構造になります。簡単な例で見てみましょう。以下にKotlinで銀行口座の残高を管理するクラスを考えます。ここでは簡単のために残高はマイナスになってはいけない、出金額は0より大きくなければいけないと定義します。
// ❌壊れやすいコードの例
class BankAccount(var balance: Int) { // 外から自由に変更できてしまう
fun withdraw(amount: Int) { // amountがゼロや負の値になる場合がある
balance -= amount // 残高がマイナスになる場合がある
}
}
val account = BankAccount(100)
// NG:外部から残高を自由に変更できてしまった
account.balance -= 50
// NG:-50 - 200 = -250 残高がマイナスになってしまった
account.withdraw(200)
// NG:不整合な残高-250が出力されてしまった
println(account.balance)
// ✅壊れにくいコードの例
data class BankAccount(val balance: UInt) { // 不変/UIntで負数を許容しない
fun withdraw(amount: UInt): BankAccount { // UIntで負数を許容しない
require(0u < amount) { "金額がゼロです" }
require(balance > amount) { "残高不足です" }
val newBalance = balance - amount // 残高の整合性が担保される
return copy(balance = newBalance) // コピーして返す
}
}
val account = BankAccount(100)
// NG:account.balanceは不変なためコンパイルエラー
// account.balance = 50u
// OK:withdrawはbalanceを書き換えず必ず新しいBankAccount(残高更新後)を返す
// ※呼び出し元が戻り値を無視しても成立してしまうため必ず使わせる制約/契約を入れる場合もある
val postWithdrawedAccount = account.withdraw(50u)
// NG:例外が発生してBankAccountは不正な状態にならない
//val postWithdrawedAccount = account.withdraw(200u)
// OK:不正な出金は構造的に排除され整合性が担保されている
println(postWithdrawedAccount.balance)
壊れにくいコードの例では制約を付けています。結果、オブジェクトが不正な状態になるのを防いでおり、どんな入力や順序で呼ばれていても口座残高というデータの整合性が壊れない構造になっています。ただし、このままだと契約までは表明できていないため、コメントで補足する必要があります。何故ならば実行時にエラー(IllegalArgumentExceptionが発生)になるため、そのエラー自体を見逃したら使用する側で不正な状態になるかもしれないからです。そしてそのチェックは開発者に委ねることになります。
構造と制約/契約とは何か?そしてどのように実現するのかについては別記事にする予定です。
壊させないために設計者がすべきこと
今、動いているコードが明日も動くとは限りません。だからこそ優れたエンジニアやアーキテクトは、壊れていない状態をよしとせず、どうすれば壊させない構造にできるかを常に考えます。コードが壊れる原因を探り、それを未然に防ぐ構造にすることが、信頼性の高いソフトウェアを育みます。それが設計です。壊れていないコードは、ただの偶然かもしれません。しかし壊させないコードは、設計者が思想と知恵で意図的に積み上げた結晶です。目指すべきは壊させないコードであり、そのために行うことは設計について学び、実践を積み重ねることに他なりません。
なので、日常の開発で自分の書いたこのコードは壊れにくいのか?を語れるよう意識してみるとよいかもしれません。設計という地盤を固めることで、コードは要件変更や予期せぬ事態などの多少の揺さぶりでは崩れにくい、強固なものへとなるでしょう。
まとめ
ソフトウェア開発とは、偶然に賭けることではありません。それはコードが壊れないことを祈る行為でもありません。それでも壊れると知っていて、それでも壊させない構造にするために設計をするのです。
設計はコードを壊されないように構造化するための意志です。
意味を刻み、構造で縛り、未来の破綻を封じる。それが壊させない設計の根源にある思想です。
次回予告
次回は壊させない設計の具体論に踏み込みます。(予定は未定ですが…)
Discussion