はじめに
この記事ではソフトウェア設計において
- 分岐を雑に扱うとどうなるのか
- 分岐を丁寧に扱うため方法とは
- 分岐を丁寧に扱うと何が得られるのか
についてまとめました。
動画も作ったのでご覧ください
分岐を雑に扱うとどうなるか??
まずはこちらのコードをご覧ください。
class DeliveryUseCase {
fun delivery(
deliveryDate: LocalDate,
purchaseAmount: Int,
previousMonthlyTotalAmount: Int?
): String {
val today = LocalDate.now()
var canTodayDelivery: Boolean
var postage: Int
if (previousMonthlyTotalAmount != null) {
if (previousMonthlyTotalAmount >= 10_000) {
if (!today.isAfter(deliveryDate)) {
canTodayDelivery = true
postage = 0
} else {
throw IllegalArgumentException("配送日が過去の日付です")
}
} else if (previousMonthlyTotalAmount >= 1_000) {
if (!today.isAfter(deliveryDate)) {
canTodayDelivery = false
if (purchaseAmount >= 3_000) {
postage = 0
}else {
postage = 50
}
} else {
throw IllegalArgumentException("配送日が過去の日付です")
}
} else {
if (!today.isAfter(deliveryDate)) {
canTodayDelivery = false
if (purchaseAmount >= 3_000) {
postage = 0
}else {
postage = 100
}
} else {
throw IllegalArgumentException("配送日が過去の日付です")
}
}
} else {
throw IllegalArgumentException("前月の購入金額を設定してください")
}
return "canTodayDelivery: $canTodayDelivery, postage: $postage"
}
}
正確に何がしたいのか理解できそうでしょうか??
これに保守開発で条件をさらに追加するにはどうでしょう??
テストコードはどんな感じで書けば品質が担保できるでしょう??
これをそのまま保守開発していくのは辛そうですね。。。
分岐を丁寧に扱う方法とは??
では先ほどのコードを改善していきましょう!!
早期エラー・早期リターンを利用した改善
class DeliveryUseCase {
fun delivery(
deliveryDate: LocalDate,
purchaseAmount: Int,
previousMonthlyTotalAmount: Int?
): String {
val today = LocalDate.now()
// 早期エラー
if (previousMonthlyTotalAmount == null) throw IllegalArgumentException("前月の購入金額を設定してください")
if (today.isAfter(deliveryDate)) throw IllegalArgumentException("配送日が過去の日付です")
// 早期リターン
if (previousMonthlyTotalAmount >= 10_000) return "canTodayDelivery: ${true}, postage: ${0}"
if (previousMonthlyTotalAmount >= 1_000) return "canTodayDelivery: ${false}, postage: ${if (purchaseAmount >= 3_000) 0 else 50}"
return "canTodayDelivery: ${false}, postage: ${if (purchaseAmount >= 3_000) 0 else 100}"
}
}
先ほどと比べてどうでしょう??
かなり見通しは良くなりましたね。
エラーを考慮しながらコードを書くと
最初のコード例のように
分岐が深いネストになったり、
コードの目的が何かぼやけてしまいます。
メイン処理よりも前に、
エラーになる可能性は全て潰しておくと、
後続の処理が楽になるのと、
今後エラーで弾くべき条件が追加されても
処理の先頭にそれらを固められて見通しが良いです。
ストラテジパターンを利用した改善
今回のようなシンプルなコードだと不要ですが、
今後の変更が激しくなるのが予想され、
より影響範囲を限定的にしつつ
変更に強くしたい場合を想定してさらに修正していきます。
その前に今回想定している仕様はこちら
・送料の算出と当日配送可否判定を行う
・会員のランクによって送料と当日配送可否が異なる
・会員のランクとランク毎の送料および当日配送可否は以下
[Gold]
条件:前月の合計購入金額が10,000円以上
送料:無料
当日配送:可能
[Silver]
条件:前月の合計金額が1,000円以上
送料:50円
当日配送:不可
[Bronze]
条件:前月の合計金額が1,000円未満
送料:100円
当日配送:不可
※ただし購入金額が3,000以上の場合送料はランクに関係なく無料
class Rank private constructor(
val rankType: RankType,
) {
companion object {
fun create(
previousMonthlyTotalAmount: Int
): UserRank {
return when {
previousMonthlyTotalAmount >= 10_000 -> Rank(RankType.GOLD)
previousMonthlyTotalAmount >= 1_000 -> Rank(RankType.SILVER)
else -> Rank(RankType.NORMAL)
}
}
}
}
enum class RankType{
NORMAL,
SILVER,
GOLD
}
ランクの判定はここでおこなう。
class GoldRankDelivery: Delivery {
override fun getPostage(purchaseAmount: Int): Int {
return 0
}
override fun canTodayDelivery(): Boolean {
return true
}
}
class SilverRankDelivery: Delivery {
override fun getPostage(purchaseAmount: Int): Int {
return if (purchaseAmount >= 3_000) 0 else 50
}
override fun canTodayDelivery(): Boolean {
return false
}
}
class NormalRankDelivery: Delivery {
override fun getPostage(purchaseAmount: Int): Int {
return if (purchaseAmount >= 3_000) 0 else 100
}
override fun canTodayDelivery(): Boolean {
return false
}
}
interface Delivery {
fun getPostage(purchaseAmount: Int): Int
fun canTodayDelivery(): Boolean
}
配送を使う側は、
このインタフェースを利用する(依存する)。
配送が行うことは以下。
- 送料算出
- 当日配送判定
companion object {
private val map: Map<RankType, Delivery> = mapOf(
RankType.NORMAL to NormalRankDelivery(),
RankType.SILVER to SilverRankDelivery(),
RankType.GOLD to GoldRankDelivery()
)
fun factory(rankType: RankType): Delivery {
return map[rankType] ?: throw IllegalArgumentException()
}
}
ランクを指定すれば該当の配送の実態を配送生成ファクトリが生成する。
class DeliveryUseCase {
fun delivery(
deliveryDate: LocalDate,
purchaseAmount: Int,
previousMonthlyTotalAmount: Int?
): String {
val today = LocalDate.now()
// 早期エラー
if (previousMonthlyTotalAmount == null) throw IllegalArgumentException("前月の購入金額を設定してください")
if (today.isAfter(deliveryDate)) throw IllegalArgumentException("配送日が過去の日付です")
val rank = Rank.create(previousMonthlyTotalAmount)
val delivery = Delivery.factory(rank.rankType)
return "canTodayDelivery: ${delivery.canTodayDelivery()}, postage: ${delivery.getPostage(purchaseAmount)}"
}
}
使う側は、
会員のランクが何か??を気にする必要がなくなり、
早期エラー以外の分岐は全てなくなりました。
ストラテジパターンを使ったことによって、
例えば、
Plutinumランクを追加する場合の
影響範囲は以下のようになり、
class Rank private constructor(
val rankType: RankType,
) {
companion object {
fun create(
previousMonthlyTotalAmount: Int
): UserRank {
return when {
previousMonthlyTotalAmount >= 100_000 -> Rank(RankType.Plutinum)
previousMonthlyTotalAmount >= 10_000 -> Rank(RankType.GOLD)
previousMonthlyTotalAmount >= 1_000 -> Rank(RankType.SILVER)
else -> Rank(RankType.NORMAL)
}
}
}
・・・
class NormalRankDelivery: Delivery {
override fun getPostage(purchaseAmount: Int): Int {
return if (purchaseAmount >= 3_000) 0 else 100
}
override fun canTodayDelivery(): Boolean {
return false
}
}
Goldランクの送料算出ロジックが変更になる場合の
影響範囲は以下のようになります、
class GoldRankDelivery: Delivery {
override fun getPostage(purchaseAmount: Int): Int {
return 20
}
・・・
}
一方で、
ストラテジパターン適用前の変更時の影響範囲は、
Plutinumランクを追加しても、
Goldランクの送料算出ロジックが変更になっても
常に全体に影響が出ます。
class DeliveryUseCase {
fun delivery(
deliveryDate: LocalDate,
purchaseAmount: Int,
previousMonthlyTotalAmount: Int?
): String {
val today = LocalDate.now()
// 早期エラー
if (previousMonthlyTotalAmount == null) throw IllegalArgumentException("前月の購入金額を設定してください")
if (today.isAfter(deliveryDate)) throw IllegalArgumentException("配送日が過去の日付です")
// 早期リターン
if (previousMonthlyTotalAmount >= 10_000) return "canTodayDelivery: ${true}, postage: ${0}"
if (previousMonthlyTotalAmount >= 1_000) return "canTodayDelivery: ${false}, postage: ${if (purchaseAmount >= 3_000) 0 else 50}"
return "canTodayDelivery: ${false}, postage: ${if (purchaseAmount >= 3_000) 0 else 100}"
}
}
この程度ならまだ問題ないですが、
ランク判定条件が無茶苦茶複雑になることが既に確定していたら??
ランクが今後さらに頻繁に変更・追加されることが既に分かっていたら??
テストコードを書くのも分岐を網羅させるのに苦労しそうですね。
変更が頻繁に入るのが予測できて、
ある程度複雑になる箇所であれば、
変更の影響を限定的にできる
ストラテジパターンを適用しておく価値があります。
テストも複雑な分岐の条件の組み合わせを考える必要がなく、
漏れなく細かく行えます。
まとめ
早期エラー・早期リターンを利用して
分岐のネストにならないように
コードの見通しを良く保ちつつ、
分岐をサクッと書いて終わっていい箇所か?
ストラテジパターンを適用しておくべき箇所か?
を丁寧に慎重に判断していきましょう。
今回のテーマについては以上です。
初めてソフトウェア設計をする方向けに記事を書きました。
ぜひこちらもご覧ください。
初めてソフトウェア設計をする方に向けて|新人プログラマー時代の自分に伝えたいこと参考文献
第2章 場合わけのロジックを整理する
現場で役立つシステム設計の原則 〜変更を楽で安全にするオブジェクト指向の実践技法