こんにちは。コミュニケーションアプリ「LINE」のモバイルクライアントを開発している石川です。
この記事は、毎週木曜の定期連載 "Weekly Report" 共有の第 3 回です。Weekly Report については、第 1 回の記事を参照してください。
戦略なき戦略
以下の Loggable
は、ログ出力する情報を保持するインターフェースです。
interface Loggable {
val logType: LogType
val logLevel: LogLevel
val logDescription: String
val timestamp: Long
val codeLocation: StackTraceElement
...
)
ここで、どのプロパティをログとして出力するかを決めるために、以下の LogAttribute
という列挙型が必要になったことを想定します。 関数 createLogMessage
は与えられた LogAttribute
を元に、どのプロパティを使ってログメッセージを構築するかを決めます。
enum class LogAttribute { LOG_TYPE, LOG_LEVEL, LOG_DESCRIPTION, ...}
fun createLogMessage(loggable: Loggable, attributesToLog: Set<LogAttribute>): String {
...
}
例えば、 setOf(LOG_LEVEL, LOG_TYPE, LOG_DESCRIPTION)
が attributesToLog
として与えられた場合、ログメッセージに含まれるプロパティは、ログレベル・種類・説明になることが期待されます。例えば "FATAL, Crash, something wrong with some parameter" のようなメッセージになるでしょう。
さらに、この LogAttribute
の順番は静的に決まっていて、変更する必要はないとします。例えば、「ログレベルはメッセージの最初に位置づけられる」という規則があるとします。この規則を実現するために、開発者は順番を決めるリスト (ORDERED_ATTRIBUTES_TO_LOG
) を定義し、それを使ってログメッセージを構築するかもしれません。
// This order might be different from `ordinal` of `LogAttribute`.
val ORDERED_ATTRIBUTES_TO_LOG: List<LogAttribute> = listOf(
LOG_LEVEL,
LOG_TYPE,
LOG_DESCRIPTION,
...
)
fun createLogMessage(loggable: Loggable, attributesToLog: Set<LogAttribute>): String =
ORDERED_ATTRIBUTES_TO_LOG.asSequence()
.filter(attributesToLog::contains)
.map { attribute ->
when (attribute) {
LogAttribute.LOG_LEVEL -> getLogLevelText(loggable)
LogAttribute.LOG_TYPE -> getLogTypeText(loggable)
...
}
}.joinToString()
このコードの問題点はありますか?
繰り返される「高度な柔軟性」
このコードでは ループの内部に分岐が存在し、かつ、各分岐はループ中に高々 1 回しか使われない という構造になっています。
これにより、以下の 2 つの問題が発生しています。
- 分岐がループの内部に直接書かれているため、関数の流れを把握するために各分岐を把握しなければならない
- 「タイプ」の順番と分岐の対応付けが取りにくい
これらの問題を解決する方法として、ここでは 4 つのオプションを紹介します。どれも長所と短所があるので、適切に使い分ける必要があります。
オプション 1: ループを削除する
「タイプを示すコレクション」が十分に小さい場合、各要素を if
で直接分岐しても良いかもしれません。
val message = StringBuilder()
if (atrributesToLog.contains(LogAttribute.LOG_LEVEL)) {
message.append(getLogLevelText(loggable))
}
if (atrributesToLog.contains(LogAttribute.LOG_TYPE)) {
message.append(getLogTypeText(loggable))
}
...
以下の getAttributeTextOrEmpty
のような補助的な関数を作ると、見通しが良くなることがあります。
val message =
loggable.getAttributeTextOrEmpty(attributesToLog, LogAttribute.LOG_LEVEL, ::getLogLevelText) +
loggable.getAttributeTextOrEmpty(attributesToLog, LogAttribute.LOG_TYPE, ::getLogTypeText) +
...
private fun Loggable.getAttributeTextOrEmpty(
attributesToLog: Set<LogAttribute>,
targetAttribute: LogAttribute,
attributeTextCreator: (Loggable) -> String
): String = if (targetAttribute in attributesToLog) attributeTextCreator(this) else ""
ただし、このオプションには難点がいつくかあります。1 つ目の難点は、すべてのタイプが網羅されているかを確認するユニットテストを書きにくいことです。もし、新たなタイプが追加されたとしても、実装漏れについて警告する手段は乏しいでしょう。2 つ目に、joinToString
のようなコレクションのユーティリティ関数が使えないという点もあります。(上のコードでは、実は ", "
で接続するコードを省いています。それを実装するともう少し複雑なコードになります。)
オプション 2: 分岐の抽出
単純に、条件分岐を補助的な関数として抽出する方法もあります。
fun createLogMessage(loggable: Loggable, attributesToLog: Set<LogAttribute>): String =
ORDERED_ATTRIBUTES_TO_LOG.asSequence()
.filter(attributesToLog::contains)
.map { attribute -> attribute.getLogText(loggable) }
.joinToString()
private fun LogAttribute.getLogText(loggable: Loggable): String = when(this) {
LogAttribute.LOG_TYPE -> ...
...
}
ただし、この方法はフローの読みにくさは一定の改善を行うものの、ORDERED_ATTRIBUTES_TO_LOG
と getLogText
の対応付けに関しては改善しません。また、分岐に else
(default
) を用いると、網羅性を保証できなくなってしまうため、else
を使わないように実装するのが無難です。
オプション 3: ロジックをタイプに埋め込む
ストラテジーパターンやそれに類似する構造を適用することで、各タイプに固有のロジックを埋め込むことができます。
enum class LogAttribute {
LOG_TYPE {
override fun getLogText(loggable: Loggable) = ...
},
LOG_LEVEL {
override fun getLogText(loggable: Loggable) = ...
},
...;
abstract fun getLogText(loggable: Loggable)
}
これを用いると、getLogText
は以下のようになります。
fun createLogMessage(loggable: Loggable, attributesToLog: Set<LogAttribute>): String =
ORDERED_ATTRIBUTES_TO_LOG.asSequence()
.filter(attributesToLog::contains)
.map { attribute -> attribute.getLogText(loggable) }
.joinToString()
この createLogMessage
からは分岐が見えないため、関数の流れがわかりやすくなっています。このオプションの利点として、網羅性が保証しやすい点があります。ロジックの実装漏れがある場合はコンパイルエラーになりますし、ORDERED_ATTRIBUTES_TO_LOG
がすべての要素を含んでいることを確認するユニットテストも書きやすいです 。
さらに、ORDERED_ATTRIBUTES_TO_LOG
を作成するために val logOrder: Int
というプロパティを各タイプに埋め込んでも良いでしょう。
val ORDERED_ATTRIBUTES_TO_LOG: List<LogAttribute> =
LogAttribute.values().sortedBy(LogAttribute::logOrder)
ただし、このオプションが使えるのは「タイプ」が他の機能で使われない場合です。そのタイプが広く使われる場合に、特定の機能固有のロジックや値を埋め込んでしまうと、依存関係が煩雑になる原因になります。
オプション 4: 関係を明示するタプルを作る
タイプとその順番、ロジックの関連性を明示するために、以下の AttributeTextModel
のようなタプルを作ることもできます。
class AttributeTextModel(val attributeType: LogAttribute, val textCreator: (Loggable) -> String)
createLogMessage
は以下のようになります。
val ORDERED_ATTRIBUTES_TO_LOG: List<AttributeTextModel> = listOf(
AttributeTextModel(LogAttribute.LOG_LEVEL, ::getLogLevelText),
AttributeTextModel(LogAttribute.LOG_TYPE, ::getLogTypeText),
...
)
fun createLogMessage(loggable: Loggable, attributesToLog: Set<LogAttribute>): String =
ORDERED_ATTRIBUTES_TO_LOG.asSequence()
.filter { attributesToLog.contains(it.attributeType) }
.map { it.textCreator(loggable) }
.joinToString()
このようにすることで、「タイプの順番が定義されているならば、対応するロジック attributesToLog
も実装されている」ことが保証できます。また、オプション 3 とは異なり、広く使われるタイプに対しても機能固有のロジックを紐付けることができます。一方、このコードでは「すべてのタイプに、対応するロジックがある」という網羅性は保証していませんが、ユニットテストを書くことでこれをカバーできます。テストをする内容としては、「各 LogAttribute
に対応する AttributeTextModel
が ORDERED_ATTRIBUTES_TO_LOG
内に存在する」ことを確認すればよいでしょう。
一言まとめ
ループ中に大きな条件分岐がある場合は、分岐とロジックの対応付けがわかりやすい構造に書き換えることを考慮する。
キーワード: loop
, conditional branch
, strategy pattern