プログラム設計・レビューの指針
はじめに
筆者は以下の動画を拝見し、今まで知っていたものの活用できていなかった設計原則を使ってプログラム設計を評価し、改善のためにデザインパターンを取り入れることを学びました。
(デザインパターンは銀の弾丸ではない)
このページでは、設計原則について記載しています。
設計原則を使用して、プログラム設計・レビューを行う際に活用できるようなっていきましょう!!
そもそも設計原則とは?
プログラミングにおける**設計原則(Design Principles)**は、読みやすく、修正しやすく、そして壊れにくいコードを書くための「ガイドライン」や「秘訣」のようなものです。
先人たちがプログラミングをしていく中で経験した「こう書くと後で苦労する」「こう書けば管理が楽になる」という知見をルール化したものらしいです。
(先人達に感謝しかありません^_^)
主な目的は以下の3点に集約されます。
- 保守性(Maintainability): 後から修正や機能追加がしやすい。
- 再利用性(Reusability): 同じようなコードを何度も書かなくて済む。
- 柔軟性(Flexibility): 変化(仕様変更など)に強い。
設計原則ってどんなのがある?
よく使われる有名は設計原則は複数あるので、箇条書きしていきます。
- DRY (Don't Repeat Yourself)
- KISS (Keep It Simple, Stupid)
- YAGNI (You Ain't Gonna Need It)
- オブジェクト指向のSOLID原則
- Single Responsibility (単一責任)
- Open/Closed (開放閉鎖)
- Liskov Substitution (リスコフ置換)
- Interface Segregation (インターフェース分離)
- Dependency Inversion (依存性逆転)
- POLP (Principle of Least Privilege / 最小権限の原則)
- POLS (Principle of Least Surprise / 驚き最小の原則)
- デメテルの法則 (Law of Demeter)
- 高凝集・疎結合 (High Cohesion, Loose Coupling)
- The Twelve-Factor App: クラウド(SaaS)
- ボーイスカウトの規則 (Boy Scout Rule)
- 情報エキスパート (Information Expert)
- クリエータ (Creator)
設計原則はたくさんありますが、ジャンルや使用するべきフェーズを整理するとどこで使えばいいのか整理できるようになると思います。
設計原則のジャンル
| ジャンル | 主な目的 | 該当する原則 |
|---|---|---|
| シンプルさ | 無駄を省く | DRY, KISS, YAGNI, Boy Scout |
| 構造の質 | 変更に強くする | SOLID, 高凝集・疎結合, 情報エキスパート |
| 安全性 | 予期せぬ破壊を防ぐ | デメテルの法則, POLP, POLS |
| 運用性 | 環境変化に耐える | The Twelve-Factor App |
設計の各フェーズ(考える・書く・見る)によって、意識すべき原則の「優先順位」が変わります。それぞれの段階でどの原則を武器にすべきか整理しました。
設計原則の使用するフェーズ
| フェーズ | 重視する合言葉 | 主な原則 |
|---|---|---|
| 設計 (Plan) | 「役割を分けよう」 | 高凝集・疎結合, SOLID, GRASP |
| 実装 (Do) | 「欲張らずシンプルに」 | KISS, DRY, YAGNI, POLP |
| レビュー (Check) | 「分かりやすく美しく」 | POLS, Boy Scout, SOLID |
以降の章では、Kotlinを用いて設計原則について確認していきたいと思います。
設計フェーズで使用する設計原則
今回使用するサンプルコードです。(注文処理システム想定)
このコードを各設計原則に当てはめて修正していこうと思います。
class OrderService {
fun processOrder(order: Order) {
if (order.items.isEmpty()) {
throw IllegalArgumentException("商品は1つ以上必要です")
}
val total = order.items.sumOf { it.price } * 1.1
// DB接続処理のリポジトリ層をそのまま呼んでいるというてい
println("DBに合計 ${total}円 で保存しました")
// メール送信処理のリポジトリ層をそのまま呼んでいるというてい
println("メールを送信しました")
}
}
高凝集・疎結合
高凝集・疎結合とは、クラスやモジュール内部の要素(メソッド、データ)が、単一の明確な目的や責任を果たすためにどれだけ強く関連しているかを示し、異なるクラスやモジュール同士が、どれだけ独立しており、お互いの内部実装に依存していないかを示します。
SOLID
SOLIDとは、保守性・拡張性の高いオブジェクト指向設計を実現するための5つの基本原則の頭文字(S: 単一責任の原則, O: オープン・クローズドの原則, L: リスコフの置換原則, I: インターフェース分離の原則, D: 依存性逆転の原則)をまとめたものです。
これにより、変更に強く、理解しやすく、再利用しやすい柔軟なコードを書くことが可能になります。
SOLID原則の5つの原則
-
S (Single Responsibility Principle) - 単一責任の原則
1つのクラス(またはモジュール)は、1つの責任(変更すべき理由)だけを持つべきである。 -
O (Open/Closed Principle) - オープン・クローズドの原則
ソフトウェアのエンティティ(クラス、モジュールなど)は、拡張に対してはオープンであり、修正に対してはクローズであるべきである(既存コードを触らずに機能追加できる)。 -
L (Liskov Substitution Principle) - リスコフの置換原則
基底クラス(親クラス)のオブジェクトを、派生クラス(子クラス)のオブジェクトに置き換えても、プログラムが正しく動作しなければならない。 -
I (Interface Segregation Principle) - インターフェース分離の原則
クライアント(利用側)が使わないインターフェースに依存させられるべきではない(巨大なインターフェースを避け、小さな専門的なインターフェースに分割する)。 -
D (Dependency Inversion Principle) - 依存性逆転の原則
高レベルモジュールは低レベルモジュールに依存すべきではなく、両者とも抽象(抽象クラスやインターフェース)に依存すべきである。
GRASP
GRASP(グラスプ)とは、General Responsibility Assignment Software Patterns(汎用的責任割り当てソフトウェアパターン)の略で、オブジェクト指向設計においてクラスやオブジェクトに「責務(役割)をどう割り当てるか」を導くための9つの基本原則・パターン群のことです。
9つの主要原則
- 情報エキスパート (Information Expert): 必要な情報を持つクラスに責務を割り当てる(カプセル化の原則)。
- 生成者 (Creator): オブジェクトを生成する責務を持つクラスに、生成するオブジェクトの責務を割り当てる。
- コントローラ (Controller): システムのイベントを処理する責務を、UI層から分離されたクラス(コントローラ)に割り当てる。
- 疎結合 (Low Coupling): クラス間の依存関係を低く保つ(再利用性・保守性向上)。
- 高凝集 (High Cohesion): 関連する責務を一つのクラスにまとめる(機能のまとまりを高める)。
- 間接化 (Indirection): 依存関係を減らすため、間接的なオブジェクト(仲介役)を導入する。
- 多態性 (Polymorphism): 共通のインターフェースを持つ複数のクラスに、状況に応じた振る舞いをさせる。
- 変動からの保護 (Protected Variations): 変更の影響を受けやすい部分を保護する。
- 純粋人工物 (Pure Fabrication): 既存のクラスに割り当てにくい責務を、専門の「人工クラス」に割り当てる。
GRASPは、これら9つの原則を総合的に利用することで、より洗練されたオブジェクト指向設計を可能にします。
コードの改善
上記のコードでは、order.items.isEmpty()やorder.items.sumOf { it.price } * 1.1hの箇所でロジックがサービス層に出てしまっています。
この状態では、Orderクラス内のプロパティとメソッドの関連性が弱く、サービスと層とOrderクラスの依存度が高い状態となっています。
高凝集・疎結合の対応
まずOrderクラスを改修します。
order.items.isEmpty()の箇所はクラス生成時にチェックするようにします。
またorder.items.sumOf { it.price } * 1.1hの箇所は関数化して、ロジックが外部に漏れないようにします。(1.1の数字もクラス変数にしています)
class Order(
private val items: List<Item>
) {
data class Item(val name: String, val price: Int)
init {
require(items.isNotEmpty()) {
throw IllegalArgumentException("商品は1つ以上必要です")
}
}
fun calculateTotalPrice(): Double {
return items.sumOf { it.price } * tax
}
companion object{
private const val tax: Double = 1.1
}
}
サービス層は新規で作成した関数を呼ぶだけで良くなります。
class OrderService {
fun processOrder(order: Order) {
val total = order.calculateTotalPrice()
println("DBに合計 ${total}円 で保存しました")
println("メールを送信しました")
}
}
疎結合 & 依存性逆転 (SOLIDのD)の対応
また、DBアクセスとメール送信処理は、抽象的なインターフェースにします。これにより、メールからLINEに変えたり、DBの種類を変えたりしても、メインのロジックを修正する必要がなくなります。
// インターフェースで抽象化(疎結合の鍵)
interface NotificationSender {
fun send(message: String)
}
interface OrderRepository {
fun save(order: Order, total: Double)
}
最終修正コード
以下のように修正することで、OrderProcessor は、「全体の流れを管理する」という1つの責任だけに集中することができます。(単一責任原則 (SOLIDのS))
また、OrderProcessorが計算式を持つのではなく、データを持っているOrderが計算を担当しているので、GRASPの情報エキスパートの原則も守られています。
class OrderService(
private val repository: OrderRepository,
private val notifier: NotificationSender
) {
fun processOrder(order: Order) {
// 金額計算はOrder(エキスパート)に任せる
val total = order.calculateTotalPrice()
// 保存と通知は、中身を知らずに「お願い」するだけ(疎結合)
repository.save(order, total)
notifier.send("注文 ${order.id} が完了しました。合計: ${total}円")
}
}
実装フェーズで使用する設計原則
次に使用するサンプルコードです。
class UserReportManager {
var userData: MutableMap<String, String> = mutableMapOf()
fun generateReport(user: User) {
// 将来使うかもと「ポイント倍率」の複雑な予備ロジックを導入
val discount = if (user.isPremium) 0.2 else 0.0
val tax = 0.1
val finalPrice = 1000 * (1 - discount) * (1 + tax)
val report = complexFormatter("User: ${user.name}, Total: $finalPrice")
println(report)
}
private fun complexFormatter(input: String): String {
// 将来の多言語対応のために...と複雑な仕組みを用意
return "[REPORT_START] " + input.uppercase() + " [REPORT_END]"
}
}
KISS
シンプルにしておけ、お馬鹿さんという、ちょっと口の悪い原則です。
ついつい凝った複雑なコードを書きがちですが、複雑なものは壊れやすく、他人が理解するのも大変です。
DRY
同じことを繰り返さないという原則です。
同じロジックが複数の場所にあると、修正が必要になった時にすべての場所を直さなければならず、修正漏れ(バグ)の原因になります。
YAGNI
それはきっと必要にならないという原則です。 「
将来使うかもしれないから、この機能も作っておこう」と予測でコードを書くのはやめましょう。
POLP(最小権限の原則)
プログラムの各部分(関数やオブジェクト)には、その仕事をするのに最低限必要な権限(データ)だけを渡すべき、という原則です。余計な情報を見せないことで、予期せぬバグを防ぎます。
コードの改善
今回の改善では、4つの原則を適用しています。
-
POLP (最小権限の原則)
データを var ではなく val にし、さらに private にします。
外部から勝手に中身を書き換えられないようにガードを固めます。 -
DRY (Don't Repeat Yourself)
計算ロジックを1箇所にまとめます。 -
YAGNI (You Ain't Gonna Need It)
「将来使うかも」という複雑なフォーマッタや予備のロジックを捨て、今必要なコードだけを書きます。 -
KISS (Keep It Simple, Stupid)
誰が見ても一瞬で理解できる書き方に直します。
class UserReportGenerator {
// 0. YAGNI: 使用されていなかった変数userDataを削除
// 1. POLP: 外部から変更不可。必要な権限だけを持つ。
private val basePrice = 1000.0
fun generateReport(user: User) {
// 2. DRY & 4. KISS: 計算をシンプルに一箇所で。
val finalPrice = calculatePrice(user.isPremium)
// 3. YAGNI: 複雑なフォーマッタを廃止し、単純な文字列テンプレートにする。
println("User: ${user.name}, Total: $finalPrice")
}
// ロジックを分離してシンプルに(KISS)
private fun calculatePrice(isPremium: Boolean): Double {
val discount = if (isPremium) 0.2 else 0.0
return basePrice * (1 - discount) * 1.1
}
}
レビューフェーズで使用する設計原則
次に使用するサンプルコードです。
class UserService(private val repository: UserRepository) {
fun updatePassword(userId: String, newPass: String) {
val user = repository.find(userId)
user.password = newPass
repository.save(user)
user.lastLogin = LocalDateTime.now()
repository.save(user)
println("OLD_LOG: User $userId updated.")
}
}
※POLSは上記で記載しているので、紹介を割愛。
POLS(驚き最小の原則)
「この関数を呼んだらこう動くはずだ」というユーザー(他のプログラマ)の予想を裏切らないように作る原則です。例えば getName() という関数の中で、こっそりデータベースの更新を行ってはいけません。
Boy Scout
コードを触ったら、触る前よりも綺麗しておくことを意識していおく考え方です。
コードレビューを行う際に、実装前と比較して汚くなっていたらやですよね?
Boy Scoutの精神を持って、コードを綺麗に保ちましょう。
コードの改善
今回の改善では、3つの原則を適用しています。
-
POLS (驚き最小の原則)
「パスワード更新」という名前の関数で、こっそりログイン時刻を更新するのは、他のプログラマを混乱させます(「なぜかパスワードを変えただけでログイン扱いになった!」というバグに繋がります)。ログイン時刻の更新は、適切な場所へ移動するか、関数名を変更します。 -
Boy Scoutの規則
修正箇所の近くに「古いログ形式(OLD_LOG)」が残っていたので、ついでに最新のロギング形式に直して、コードを少しだけ綺麗にしておきます。 -
SOLID(特に L: リスコフ置換 / I: インターフェース分離)
レビューでは「このクラスの使い方は正しいか?」をチェックします。ここでは、依存している UserRepository が巨大すぎないか(I)、子クラスで壊れた実装になっていないか(L)を考慮した形に整理します。
// I: 必要な機能(保存と検索)だけに絞ったインターフェースを定義
interface UserUpdatePort {
fun find(id: String): User
fun save(user: User)
}
class UserService(private val userUpdatePort: UserUpdatePort) {
private val logger = CustomLogger()
fun updatePassword(userId: String, newPass: String) {
val user = userUpdatePort.find(userId)
// パスワード更新に専念(POLS: ログイン時刻更新などの余計なことはしない)
user.password = newPass
userUpdatePort.save(user)
// ボーイスカウト:古い println を最新のロガーに改善して去る
logger.info("Password updated for user: $userId")
}
}
まとめ
設計原則を使用したプログラム設計・レビューの活用方法について記載してみましたが、いかがだったでしょうか?
無意識のうちに確認している原則や観点として漏れていた原則もあったのではないでしょうか?
次は、プログラムを改修する際にデザインパターンからプログラムを修正していくことを行っていきたいと思っています。
読んでいただきありがとうございました!!!
Discussion