【Kotlin】0はじまりか1はじまりかが紛らわしいのでクラスで表す

[「仕様の変更に強いコードを書きたいよねって話」](https://qiita.com/JSON_HardCoder/items/09b7c3136bdad36c4830) という記事の[コメント](https://qiita.com/JSON_HardCoder/items/09b7c3136bdad36c4830#comment-d77e91bf3217b982a9cf)に
「序数を扱うクラスを定義して、1 始まりなのか 0 始まりなのかをプログラマーが意識しなくてよいようにする」ということを書いたのだが、
それを Kotlin でやってみた。

## 序数を表すクラスなどを定義する

まず、序数を表すクラスなどの、汎用的な型を定義する。

| 型 | 説明 |
|---|---|
| Ordinal | 序数（順番を表す数） |
| Cardinal | 基数（数量を表す数） |
| OrdinalRange | 序数の範囲 |

```Kotlin: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)
```

```Kotlin: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)
```

```Kotlin: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 始まりのインデックスで扱う関数とに分割した。

```Kotlin: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 テストも書いた。

```Kotlin: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 始まりを区別する」方針の方が良さそう。