「仕様の変更に強いコードを書きたいよねって話」 という記事のコメントに
「序数を扱うクラスを定義して、1 始まりなのか 0 始まりなのかをプログラマーが意識しなくてよいようにする」ということを書いたのだが、
それを Kotlin でやってみた。
序数を表すクラスなどを定義する
まず、序数を表すクラスなどの、汎用的な型を定義する。
| 型 | 説明 |
|---|---|
| Ordinal | 序数(順番を表す数) |
| Cardinal | 基数(数量を表す数) |
| OrdinalRange | 序数の範囲 |
Ordinal.kt
/**
* 序数(順番を表す数)。
*
* @param zeroBasedIndex このオブジェクトが表す数を 0 始まりのインデックスで表したもの。
*/
class Ordinal private constructor(
val zeroBasedIndex: Int
) : Comparable<Ordinal> {
/** このオブジェクトが表す数を 1 始まりのインデックスで表したもの。 */
val oneBasedIndex: Int
get() = zeroBasedIndex + 1
override fun compareTo(other: Ordinal): Int =
this.zeroBasedIndex.compareTo(other.zeroBasedIndex)
companion object {
val ORIGIN: Ordinal = Ordinal(0)
/** 0 始まりのインデックスから [Ordinal] オブジェクトを生成する。 */
fun fromZeroBasedIndex(zeroBasedIndex: Int): Ordinal =
Ordinal(zeroBasedIndex)
/** 1 始まりのインデックスから [Ordinal] オブジェクトを生成する。 */
fun fromOneBasedIndex(oneBasedIndex: Int): Ordinal =
Ordinal(oneBasedIndex - 1)
}
}
operator fun Ordinal.inc(): Ordinal = this + 1
operator fun Ordinal.dec(): Ordinal = this - 1
operator fun Ordinal.plus(other: Cardinal): Ordinal = Ordinal.fromZeroBasedIndex(zeroBasedIndex + other.value)
operator fun Ordinal.minus(other: Cardinal): Ordinal = Ordinal.fromZeroBasedIndex(zeroBasedIndex - other.value)
operator fun Ordinal.plus(int: Int): Ordinal = Ordinal.fromZeroBasedIndex(zeroBasedIndex + int)
operator fun Ordinal.minus(int: Int): Ordinal = Ordinal.fromZeroBasedIndex(zeroBasedIndex - int)
Cardinal.kt
/**
* 基数(数量を表す数)。
*/
class Cardinal private constructor(
val value: Int
) : Comparable<Cardinal>, OrdinalRange {
override fun compareTo(other: Cardinal): Int =
this.value.compareTo(other.value)
override val start: Ordinal
get() = Ordinal.ORIGIN
override val endInclusive: Ordinal
get() = Ordinal.fromZeroBasedIndex(value - 1)
companion object {
val ZERO: Cardinal = Cardinal(0)
/** [Cardinal] オブジェクトを生成する。 */
fun from(value: Int): Cardinal = Cardinal(value)
}
}
operator fun Cardinal.plus(other: Cardinal): Cardinal = Cardinal.from(this.value + other.value)
operator fun Cardinal.minus(other: Cardinal): Cardinal = Cardinal.from(this.value - other.value)
operator fun Cardinal.times(other: Cardinal): Cardinal = Cardinal.from(this.value * other.value)
operator fun Cardinal.div(other: Cardinal): Cardinal = Cardinal.from(this.value / other.value)
operator fun Cardinal.plus(int: Int): Cardinal = this + Cardinal.from(int)
operator fun Cardinal.minus(int: Int): Cardinal = this - Cardinal.from(int)
operator fun Cardinal.times(int: Int): Cardinal = this * Cardinal.from(int)
operator fun Cardinal.div(int: Int): Cardinal = this / Cardinal.from(int)
OrdinalRange.kt
/**
* 序数の範囲。
*/
interface OrdinalRange : ClosedRange<Ordinal>, Iterable<Ordinal> {
override fun iterator(): Iterator<Ordinal> = sequence {
var current = start
while (current <= endInclusive) {
yield(current++)
}
}.iterator()
}
/**
* [OrdinalRange] オブジェクトを生成する。
*
* @param start 範囲の下限。この値を範囲に含む。
* @param endInclusive 範囲の上限。この値を範囲に含む。
*/
fun OrdinalRange(start: Ordinal, endInclusive: Ordinal) = object : OrdinalRange {
init {
require(start <= endInclusive + 1)
}
override val start = start
override val endInclusive = endInclusive
}
/** 範囲の上限。この値を範囲に含まない。 */
val OrdinalRange.endExclusive: Ordinal
get() = endInclusive + 1
/**
* [OrdinalRange] オブジェクトを生成する。
*
* @receiver 範囲の下限。この値を範囲に含む。
* @param that 範囲の上限。この値を範囲に含む。
*/
operator fun Ordinal.rangeTo(that: Ordinal): OrdinalRange = OrdinalRange(this, that)
/**
* [OrdinalRange] オブジェクトを生成する。
*
* @receiver 範囲の下限。この値を範囲に含む。
* @param to 範囲の上限。この値を範囲に含まない。
*/
infix fun Ordinal.until(to: Ordinal): OrdinalRange = OrdinalRange(this, to - 1)
拡張関数・拡張プロパティにできるものはできるだけそうする方針で実装。
ページネーションを行う関数を実装
数を Ordinal と Cardinal だけで扱う関数と
1 始まりのインデックスで扱う関数とに分割した。
Pagination.kt
package pagination
/**
* 現在の要素を含むページに含まれる要素の順番のリストを返す。
*
* @param currentNumber 現在の要素の順番(0 始まり)。
* @param total 要素の総数。
* @param size ページあたりに含まれる要素の最大数。
* @return [currentNumber] を含むページに含まれる全ての要素の順番(0 始まり)のリスト。
*/
fun getItemNumberListInCurrentPage(currentNumber: Int, total: Int, size: Int): List<Int> =
getCurrentPage(
current = Ordinal.fromOneBasedIndex(currentNumber),
total = Cardinal.from(total),
size = Cardinal.from(size)
)
.map { ordinal -> ordinal.oneBasedIndex }
/**
* 現在の要素を含むページに含まれる範囲を返す。
*
* @param current 現在の要素の順番。
* @param total 要素の総数。
* @param size ページあたりに含まれる要素の最大数。
* @return [current] を含むページに含まれる要素の範囲。
*/
fun getCurrentPage(current: Ordinal, total: Cardinal, size: Cardinal): OrdinalRange {
require(current in total)
require(total >= Cardinal.ZERO)
require(size > Cardinal.ZERO)
if (total <= size) {
return total
}
val provisionalRange =
(size / 2).let { flooredHalfSize ->
val ceiledHalfSize = size - flooredHalfSize
(current - flooredHalfSize) until (current + ceiledHalfSize)
}
return when {
provisionalRange.start < total.start ->
size
provisionalRange.endExclusive > total.endExclusive ->
(Ordinal.ORIGIN + (total - size)) until total.endExclusive
else ->
provisionalRange
}
}
テスト
JUnit テストも書いた。
Pagination.kt
import org.hamcrest.CoreMatchers.`is`
import org.hamcrest.CoreMatchers.instanceOf
import org.junit.Assert.assertThat
import org.junit.Test
import java.lang.IllegalArgumentException
class PaginationTest {
@Test
fun testTypical() {
assertThat(
getItemNumberListInCurrentPage(currentNumber = 5, total = 10, size = 10),
`is`(listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10))
)
}
@Test
fun testOddSize() {
assertThat(
getItemNumberListInCurrentPage(currentNumber = 1, total = 5, size = 3),
`is`(listOf(1, 2, 3))
)
assertThat(
getItemNumberListInCurrentPage(currentNumber = 2, total = 5, size = 3),
`is`(listOf(1, 2, 3))
)
assertThat(
getItemNumberListInCurrentPage(currentNumber = 3, total = 5, size = 3),
`is`(listOf(2, 3, 4))
)
assertThat(
getItemNumberListInCurrentPage(currentNumber = 4, total = 5, size = 3),
`is`(listOf(3, 4, 5))
)
assertThat(
getItemNumberListInCurrentPage(currentNumber = 5, total = 5, size = 3),
`is`(listOf(3, 4, 5))
)
}
@Test
fun testEvenSize() {
assertThat(
getItemNumberListInCurrentPage(currentNumber = 1, total = 4, size = 2),
`is`(listOf(1, 2))
)
assertThat(
getItemNumberListInCurrentPage(currentNumber = 2, total = 4, size = 2),
`is`(listOf(1, 2))
)
assertThat(
getItemNumberListInCurrentPage(currentNumber = 3, total = 4, size = 2),
`is`(listOf(2, 3))
)
assertThat(
getItemNumberListInCurrentPage(currentNumber = 4, total = 4, size = 2),
`is`(listOf(3, 4))
)
}
@Test
fun testTotalLessThanSize() {
assertThat(
getItemNumberListInCurrentPage(currentNumber = 1, total = 2, size = 3),
`is`(listOf(1, 2))
)
assertThat(
getItemNumberListInCurrentPage(currentNumber = 2, total = 2, size = 3),
`is`(listOf(1, 2))
)
}
@Test
fun testIllegalArgument() {
assertThat(
runCatching { getItemNumberListInCurrentPage(currentNumber = 0, total = 10, size = 10) }.exceptionOrNull(),
`is`(instanceOf(IllegalArgumentException::class.java))
)
assertThat(
runCatching { getItemNumberListInCurrentPage(currentNumber = 11, total = 10, size = 10) }.exceptionOrNull(),
`is`(instanceOf(IllegalArgumentException::class.java))
)
assertThat(
runCatching { getItemNumberListInCurrentPage(currentNumber = 1, total = 0, size = 10) }.exceptionOrNull(),
`is`(instanceOf(IllegalArgumentException::class.java))
)
assertThat(
runCatching { getItemNumberListInCurrentPage(currentNumber = 1, total = 10, size = 0) }.exceptionOrNull(),
`is`(instanceOf(IllegalArgumentException::class.java))
)
}
}
感想
Kotlin だからそれなりに読めるように書けたが、
他の言語なら「注意して 0 始まりと 1 始まりを区別する」方針の方が良さそう。