LoginSignup
8
4

【Kotlin】for 文で LocalDate を Int のように扱う

Posted at

TL;DR

  • 実用的には datesUntil() を使うのがてっとり早い
  • Kotlin の for 文は iterator() メソッドで取得したイテレーターでループしている
  • 次のようにして Int 型と同じようにループできる
    • iterator() メソッドを定義した Range クラスを作成
    • rangeTo() メソッドをオーバーライドしてその Range クラスを返す

はじめに

Kotlin で Int 型のループは次のように書くことが可能です。

Int 型のループ
val start = 1
val end = 10

for (x in start..end) {
  println(x)
}

LocalDate 型のループも同じように書けると思いきや、これはコンパイルエラーになります。

LocalDate 型のループ
val startDate = LocalDate.of(2022, 1, 1)
val endDate = LocalDate.of(2022, 1, 31)

for (date in startDate..endDate) {
  println(date)
}
エラーメッセージ
For-loop range must have an 'iterator()' method 

本記事の目的

  • エラーを読み解き、Kotlin の for 文を理解する
  • for 文で Int 型と同じように LocalDate 型を扱えるようにする

datesUntil を使う

LocalDate には datesUntil というメソッドが存在し、次のような書き方で日付のイテレーションは可能です。ループするだけならこれで済みますが、本記事では for 文をきちんと理解することを目的として実装を行います。

startDate.datesUntil(endDate.plusDays(1)).forEach { println(it) }

for 文の基本

公式ドキュメントによると、ループ対象のオブジェクトに必要な条件は次の3つです。

  • メンバー関数または拡張関数として iterator() を持っていて、Iterator<> を返すこと
  • その Iterator<> は、メンバー関数または拡張関数として next() を持っていること
  • その Iterator<> は、メンバー関数または拡張関数として hasNext() を持っていること

つまり、Kotlin の for 文はイテレーターでループしているだけで、以下の for 文のループ と while文のループ は実質的に同じ処理になります。

for文のループ
val list: List<Int> = listOf(1, 2, 3)

for (i in list) {
  println(i)
}
while文のループ
val list: List<Int> = listOf(1, 2, 3)
val iterator: Iterator<Int> = list.iterator()

while (iterator.hasNext()) {
  val i = iterator.next()
  println(i)
}

つまり For-loop range must have an 'iterator()' method というエラーメッセージが言っているのは、startDate..endDate で作成したオブジェクトが iterator() メソッドを持ってないよ、ということです。

逆に言えば 1..10 のように作成したオブジェクトは iterator() メソッドを持っているわけですが、これは何者なのでしょうか?

Int 型のループを理解する

Int 型のループがどういう仕組みで動作しているのか見ていきましょう。

題材にする for ループ
for (x in 1..10) {
  println(x)
}

IntRange

1..10.. は範囲を表現するための演算子で、rangeTo メソッドで実装されています。
そのため、明示的にメソッドを呼んでも同じように動作します。

val range: IntRange = 1.rangeTo(10)
for (x in range) {
  println(x)
}

rangeTo メソッドの戻り値は IntRange です。Int の「範囲」を表すクラスで、端点の値(startendInclusive)をプロパティとして持っています。

このクラスの iterator() メソッドでは start から endInclusive まで数値が 1 ずつ増えるイテレーターが返ります(実際には親クラスの IntProgression で定義されています。長くなるので説明は割愛します)。

LocalDate 型でループする

ClosedRange

LocalDate は Java のクラスであり、rangeTo メソッドを持ちませんが、Kotlin 標準ライブラリに Comparable<T>.rangeTo メソッドが定義されているため Range を作成可能です。ただしその戻り値は ClosedRange<T> 型です。

val start = LocalDate.of(2022, 1, 1)
val end = LocalDate.of(2022, 12, 31)

val range: ClosedRange<LocalDate> = start..end

これは「範囲」を表すインターフェースで、端点の値(startendInclusive)を持っているだけです。IntRange にはあった iterator() メソッドが無いのでループできません。

LocalDateRange を作る

ClosedRangeiterator() メソッドが存在しないからループできないので、iterator() メソッドを持ったクラスを返すように変更してみましょう。

まずは ClosedRange を継承した LocalDateRange クラスを作成します。

class LocalDateRange(
    override val start: LocalDate,
    override val endInclusive: LocalDate
) : ClosedRange<LocalDate>

LocalDate の拡張関数として rangeTo メソッドを定義して LocalDateRange を返すようにします。

operator fun LocalDate.rangeTo(that: LocalDate) = LocalDateRange(this, that)

これで .. 演算子を使ったときに LocalDateRange が返されるようになりました。

val start = LocalDate.of(2022, 1, 1)
val end = LocalDate.of(2022, 12, 31)
val range: LocalDateRange = start..end

この時点では ClosedRange と変わらないので、まだループはできません。

LocalDateIterator を作る

LocalDateRange クラスに Iterable<LocalDate> インターフェースをつけて、iterator() メソッドを実装します。

class LocalDateRange(
    override val start: LocalDate,
    override val endInclusive: LocalDate
) : ClosedRange<LocalDate>, Iterable<LocalDate> {
    override fun iterator(): Iterator<LocalDate> = LocalDateIterator(start, endInclusive)
}

そこで返すイテレーターは次のように定義しています。このクラスはふつうのイテレーターです。内部状態として次に返す値(next)を持っていて、要素を取り出すたびに1日日付を進めます。終了日(endInclusive)を超えたら hasNext()false を返すようになります。

class LocalDateIterator(
    val start: LocalDate,
    val endInclusive: LocalDate
) : Iterator<LocalDate> {
    var next = start

    override fun hasNext(): Boolean {
        return next <= endInclusive
    }

    override fun next(): LocalDate {
        val returnValue = next
        next = next.plusDays(1)
        return returnValue
    }
}

これで次のように startDate から endDate まで1日ずつループすることが可能になります。

val startDate = LocalDate.of(2022, 1, 1)
val endDate = LocalDate.of(2022, 1, 31)

for (date in startDate..endDate) {
  println(date)
}

補足

ここまでで for 文でループ可能になりましたが、2点補足です。

1. Itarable は必須ではない

for 文でループするには iterator() メソッドが必須ですが、実は Iterable インターフェースは必須ではありません。インターフェースを使わない場合は iterator() メソッドに operator を付けてやれば for 文でループ可能です。

Iterable を使わない場合
class LocalDateRange(
    override val start: LocalDate,
    override val endInclusive: LocalDate
) : ClosedRange<LocalDate> {
    operator fun iterator(): Iterator<LocalDate> = LocalDateIterator(start, endInclusive)
}

2. Iterator クラスも必須ではない

実はイテレーターはクラスとして定義する必要もなく、iterator 関数を使うと簡単に定義できます。詳細はリファレンスを参照していただきたいのですが、簡単に言えば next() を呼ぶたびに yield() に渡した値を返すイテレーターが作成されます。

class LocalDateRange(
    override val start: LocalDate,
    override val endInclusive: LocalDate
) : ClosedRange<LocalDate> {
    operator fun iterator(): Iterator<LocalDate> = iterator {
        var date = start
        while (date <= endInclusive) {
            yield(date) // ここで処理は中断。next() が呼ばれると値を返して次の行へ処理が進みます。
            date = date.plusDays(1)
        }
    }
}

最終的なコード

// LocalDateRange の定義。
class LocalDateRange(
    override val start: LocalDate,
    override val endInclusive: LocalDate
) : ClosedRange<LocalDate> {
    
    // iterator 関数で Iterator<LocalDate> を作成
    operator fun iterator(): Iterator<LocalDate> = iterator {
        var date = start
        while (date <= endInclusive) {
            yield(date)
            date = date.plusDays(1)
        }
    }
}

// .. 演算子で LocalDateRange を返すようにする
operator fun LocalDate.rangeTo(that: LocalDate) = LocalDateRange(this, that)

fun main() {
    val startDate = LocalDate.of(2023, 1, 1)
    val endDate = LocalDate.of(2023, 1, 31)

    // 2023-01-01 から 2023-01-31 まで出力
    for (date in startDate..endDate) {
        println(date)
    }
}
8
4
1

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
8
4