1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

1
Last updated at Posted at 2019-12-14

「仕様の変更に強いコードを書きたいよねって話」 という記事のコメント
「序数を扱うクラスを定義して、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)

拡張関数・拡張プロパティにできるものはできるだけそうする方針で実装。

ページネーションを行う関数を実装

数を OrdinalCardinal だけで扱う関数と
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 始まりを区別する」方針の方が良さそう。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?