KINTO Technologies Advent Calendar 2021 - Qiitaの21日目の記事です。
Semantic Versioningに従ったアプリのバージョンチェック実装方法を例に、OOPじゃないコードとOOPのコードを比べてなぜOOPにするべきかを説明します。
バージョンは1.2.3
みたいにMAJOR.minor.Patch
の三つの数字でできている文字列で、単純に文字列を比べたらバージョンの前後の判断が出来ない。例えば、1.0.10
と1.0.9
を比べると1.0.9
の方が新しいバージョンだと認識してしまう問題が有るため、3つに分けてそれぞれ比べる必要があります。<pre-release>
や<build>
があるバージョンも正しく比較できません。
Take #1 : 専用メソッド
簡単にするためprivate
メソッドでバージョンをチェックするロジックを実装。
class Take1 {
fun someMethod(currentVersion: String, minVersion: String) {
if (updateRequired(currentVersion, minVersion)) {
// Update
}
}
private fun updateRequired(currentVersion: String, minVersion: String): Boolean {
val current = currentVersion.replace("[^.\\d]".toRegex(), "").split(".")
val min = minVersion.replace("[^.\\d]".toRegex(), "").split(".")
return current[0].toInt() < min[0].toInt() ||
(current[0].toInt() == min[0].toInt() && current[1].toInt() < min[1].toInt()) ||
(current[0].toInt() == min[0].toInt() && current[1].toInt() == min[1].toInt() && current[2].toInt() < min[2].toInt())
}
}
fun main(args: Array<String>) {
Take1().someMethod(args[0], args[1])
}
バージョンチェックは出来るが、
-
private
メソッドのため、unit test作成できない。 - 再使用できない。
- 更新が必要かどうか以外のことは判断できない。たとえば、
- どのバージョン番号(
MAJOR
,minor
,Patch
)で判定が変わるか。 -
<pre-release>
や<build>
が有るかどうか。 - その他
- どのバージョン番号(
の限界が有る。
Take #2 : Helperオブジェクト
更新が必要かどうか判断ロジックを別クラスに分離して、public
にする。
object Helper {
fun isUpdateRequired(currentVersion: String, minVersion: String): Boolean {
val current = currentVersion.replace("[^.\\d]".toRegex(), "").split(".")
val min = minVersion.replace("[^.\\d]".toRegex(), "").split(".")
return current[0].toInt() < min[0].toInt() ||
(current[0].toInt() == min[0].toInt() && current[1].toInt() < min[1].toInt()) ||
(current[0].toInt() == min[0].toInt() && current[1].toInt() == min[1].toInt() && current[2].toInt() < min[2].toInt())
}
}
class Take2 {
fun someMethod(currentVersion: String, minVersion: String) {
if (Helper.isUpdateRequired(currentVersion, minVersion)) {
// Update
}
}
}
fun main(args: Array<String>) {
if (Take2.isUpdateRequired(args[0], args[1])) {
// Update
}
}
アップデートが必要かの判定ロジックをHelper.isUpdateRequired
に分離した。これでTake2
のコードはTake1
に比べて短くなり、バージョンチェックのロジックは
- unit testの作成がやりやすくなる。
- 判定ロジックの再使用が出来る。
ようになった。だがまだ更新が必要かどうか以外のことは判断できないという問題は残っている
Take #3 : SemanticVersion
オブジェクト
Semantic Versioningのルールに合う文字列を解析してオブジェクト化できるclassを定義する。
class SemanticVersion : Comparable<SemanticVersion> {
@Suppress("MemberVisibilityCanBePrivate")
companion object {
const val PATTERN = "^(\\d+)\\.(\\d+)\\.(\\d+)(-[^+]+)?(\\+.+)?$"
val REGEX = PATTERN.toRegex()
}
val major: Int
val minor: Int
val patch: Int
val preRelease: String?
val build: String?
constructor(version: String) {
val result = REGEX.find(version)
?: throw IllegalArgumentException("illegal version : version=$version")
major = result.groupValues[1].toInt()
minor = result.groupValues[2].toInt()
patch = result.groupValues[3].toInt()
preRelease = result.groupValues[4].let {
if (it.isEmpty())
null
else
it.substring(1)
}
build = result.groupValues[5].let {
if (it.isEmpty())
null
else
it.substring(1)
}
}
override fun compareTo(other: SemanticVersion): Int {
var rv = major.compareTo(other.major)
if (0 == rv)
rv = minor.compareTo(other.minor)
if (0 == rv)
rv = patch.compareTo(other.patch)
return rv
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SemanticVersion
return major == other.major
&& minor == other.minor
&& patch == other.patch
&& preRelease == other.preRelease
&& build == other.build
}
override fun hashCode(): Int {
var result = major
result = 31 * result + minor
result = 31 * result + patch
result = 31 * result + (preRelease?.hashCode() ?: 0)
result = 31 * result + (build?.hashCode() ?: 0)
return result
}
override fun toString(): String {
var rv = "$major.$minor.$patch"
if (null != preRelease)
rv = "$rv-$preRelease"
if (null != build)
rv = "$rv+$build"
return rv
}
}
class Take3 {
fun someMethod(currentVersion: String, minVersion: String) {
val current = SemanticVersion(currentVersion)
val min = SemanticVersion(minVersion)
when {
current > min -> // Do something
current < min -> // Update
else -> // Do something
}
}
}
fun main(args: Array<String>) {
Take3().someMethod(args[0], args[1])
}
SemanticVersion
にSemantic Versioningの仕様を実装し、バージョンの上下を比べるだけではなく色んな機能ができた。
- 上下判定後にもバージョンの詳細情報を使えるようになった。
- 各バージョン番号と上下の判定に関わらない
<pre-release>
と<build>
も取れるように変更。(再使用性改善) -
preRelease
とbuild
属性がnull
かどうかでも判断できるが、hasPreRelease
みたいな属性も簡単に追加できる。(拡張性改善) -
Comparable<SemanticVersion>
インタフェースを実装することで<
,==
,>
などのsymbolが使えるようになった。(コーディングの生産性改善) - 正規表現式でバージョン文字列が正しいか判定できる。(安定性の改善)
それでOOPの話は?
SemanticVersion
を定義する前もclassを使ったのになぜSemanticVersion
のみOOPか?
OOPはデータとそのデータを使うロジックを一緒に管理するデザインパターン。色んなプログラミング言語が採用し、OOPがやりやすい文法の最も重要な概念になっている。(理論的にはassemblyもOOPはできるが現実的ではない)
Semantic Versioningはmajor
, minor
, patch
, pre-release
, build
のデータ(state)と上下を比べる機能(operation)が必要。
- データ(state) :
major
,minor
,patch
,preRelease
,build
- ロジック(operator) :
constructor
(s),compareTo(SemanticVersion)
,toString()
などのメソッド。
データとロジックを一緒にハンドリングできるようにまとめるclassがSemanticVersion
。
Take1
とTake3
のコードを比べると
class Take1 {
fun someMethod(currentVersion: String, minVersion: String) {
// ここにはstateへアクセスできる方法がない。
if (updateRequired(currentVersion, minVersion)) {
// 文字列からmajor, minor, patchのstateを取得したがreturn時になくなり使えない。
}
}
private fun updateRequired(currentVersion: String, minVersion: String): Boolean {
val current = currentVersion.replace("[^.\\d]".toRegex(), "").split(".")
val min = minVersion.replace("[^.\\d]".toRegex(), "").split(".")
return current[0].toInt() < min[0].toInt() ||
(current[0].toInt() == min[0].toInt() && current[1].toInt() < min[1].toInt()) ||
(current[0].toInt() == min[0].toInt() && current[1].toInt() == min[1].toInt() && current[2].toInt() < min[2].toInt())
}
}
updateRequired
メソッドの実行中にstateを取得したがreturn
と共になくなり使う方法が無くなる。つまりバージョンを比べた結果はあるがその判断元のデータはどこにも存在しない。currentVersion
とminVersion
文字列から取得できるが文字列はその機能(operator又はmethod)を持っていない。
class Take3 {
fun someMethod(currentVersion: String, minVersion: String) {
val current = SemanticVersion(currentVersion)
val min = SemanticVersion(minVersion)
// current.compareTo(...)が使える。
when {
current > min -> // Do something
current < min -> // Update
else -> // Do something
}
// ここからもcurrent.majorなどのstateを取得できる。
// 何か他のメソッドを呼ぶときcurrentをパラメタで渡すと向こうのコードもバージョンの全てのstateとoperatorが使える。
}
}
Take3
はオブジェクト化したcurrent
のリファレンスを持っている限りcurrent.major
のようにstateを何回も取得できるし、オブジェクトのリファレンスを渡すことでどんなコードでも漏れや間違いなくcurrent
のデータ(state)と機能(operator)が使える。
結論
ソフトウェアが複雑なら複雑ほどデメリットよりメリットの方が大きい。だからOOP使いましょう。
メリット
- データ(state)と機能(operator)が一緒にいるためデータの使い方が明確。
- ロジックの多い部分をオブジェクトに任せて呼ぶ方のコードがシンプルになる。
- ロジックのcontextの多い部分をオブジェクトが要約するため、コードが読みやすい。
- 変更の影響範囲が明確な傾向がある。
デメリット
- オブジェクトを再使用しないとコードが無駄に長い傾向がある。
- 再使用されていると変更の影響範囲が広い。
- 再使用されているとバグがあったら影響範囲が広い。
当社では、トヨタ車のサブスク「KINTO」等の企画/開発を行っており、エンジニアを募集中です。
KINTO Technologies コーポレートサイト