Monday 31 October 2022

Kotlin: ranges

G'day:

Yet another example of me starting a Kotlin koans exercise, seeing the first interesting word in the koans task description and getting lost in researching how the thing works.

Test class names

This is just an aside. You know how I've been showing all these test methods like this (spoiler for the next section):

@Test
fun `a range of integers should be an IntRange`() {
    val range = 1..10

    range.shouldBeInstanceOf<IntRange>()
}

Where the method name is in back-ticks and a human-readable sentence? On a whim I went "I wonder if…" and I tried this:

internal class `Tests of ranges` {

    @Nested
    inner class `Tests of ranges on Ints` {
    // etc

And it only bloody works on class names too! How cool is that?

In the test output that lot shows like this:


Ranges

From the docs:

A range defines a closed interval in the mathematical sense: it is defined by its two endpoint values which are both included in the range.

Let's look at some tests that show how they work:

fun `it should return true for a value in a range`() {
    val range = 1..10
    val value = 5

    (value in range) shouldBe true
}
fun `it should return false for a value not in a range`() {
    val range = 1..10
    val value = 11

    (value in range) shouldBe false
}
fun `it should return true for values at the boundaries of the range`() {
    val range = 1..10

    (1 in range) shouldBe true
    (10 in range) shouldBe true
}

Those are pretty straight forward. What type is it?

fun `a range of integers should be an IntRange`() {
    val range = 1..10

    range.shouldBeInstanceOf<IntRange>()
}

And how to use it?

fun `it can be iterated over`() {
    val range = 1..10
    var list = mutableListOf<Int>()

    list = range.fold(list) { acc, i -> acc.apply { add(i) } }

    list shouldBe listOf(1,2,3,4,5,6,7,8,9,10)
}

It can have a step:

fun `it should return true for values at the steps`() {
    val range = 1..10 step 2

    listOf(1,3,5,7,9).forEach {
        (it in range) shouldBe true
    }
}

Also be mindful of what elements are in the range:

@Test
fun `the upper bound might not be included`() {
    val range = 1 until 10 step 2

    (10 in range) shouldBe false
}
fun `it should return false for values not at the steps`() {
    val range = 1..10 step 2

    listOf(2,4,6,8,10).forEach {
        (it in range) shouldBe false
    }
}

Note the type on a stepped-range is different:

fun `a stepped range is an IntProgression`() {
    val range = 1..10 step 2

    range.shouldNotBeInstanceOf<IntRange>()
    range.shouldBeInstanceOf<IntProgression>()
}

They work in reverse:

fun `a step can be downwards`() {
    val range = 10 downTo 1 step 2

    listOf(10,8,6,4,2).forEach {
        (it in range) shouldBe true
    }
    (1 in range) shouldBe false
}

And one can access some properties about the range:

fun `it has some properties`() {
    val range = 1..10 step 2

    range.first shouldBe 1
    range.last shouldBe 9
    range.step shouldBe 2
}

Note how it's the max value here, not the upper bound of the range.

And they can be reversed (and by inference here, compared):

fun `it can be reversed`() {
    val range = 1..10 step 2
    val reversed = range.reversed()

    (reversed == (9 downTo 1 step 2)) shouldBe true
}

One can make a range of doubles, but they're not iterable:

fun `it does not implement iterator like a range of ints does`() {
    val intRange = 1..10
    intRange.shouldBeInstanceOf<Iterable<*>>()

    val doubleRange = 1.0..10.0
    doubleRange.shouldNotBeInstanceOf<Iterable<*>>()
}

However one can still check whether something is within the range:

fun `its elements can be compared to other values`() {
    val range = 1.0..10.0
    val value = 5.5

    (value in range) shouldBe true
}

Ranges of chars are more useful (I guess…):

fun `a range of chars can have a step`() {
    val range = 'z' downTo 'a' step 2

    listOf('z','x','v','t','r','p','n','l','j','h','f','d','b').forEach {
        (it in range) shouldBe true
    }
}

One can have ranges of strings, but they don't seem to support stepping or downto

fun `a range of strings can have a step`() {
    val range = "a".."f"

    listOf("a","b","c","d","e","f").forEach {
        (it in range) shouldBe true
    }
}

But they can be multi-char:

fun `the strings in the range can be multi-character`() {
    val range = "aa".."cc"

    listOf("aa","ab","ac","ba","bb","bc","ca","cb","cc").forEach {
        (it in range) shouldBe true
    }
}

I tried to be clever and create a range of strings and then reverse it, but Kotlin sez no:

I can make a range of enum elements (which I thought was at least a bit clever of me to try):

@Test
fun `a range of MaoriNumbersEnum should not allow other elements from the enum`() {
    val range = MI.TAHI..MI.WHA // tahi, rua, toru, wha

    (MI.RUA in range) shouldBe true
    (MI.RIMA in range) shouldBe false // rima is 5
}

@Test
fun `a range of enums is not iterable` () {
    val range = MI.TAHI..MI.WHA

    range.shouldNotBeInstanceOf<Iterable<*>>()
}

But as you can see, they too aren't iterable.

I think I need to set myself an assignment to write extension functions so I can iterate over my Maori numbers. But not this evening.

Lastly - and so I can answer the koans question - any class that implements Comparable can be used in a range.

Here's my MyDate class back from the previous article:

data class MyDate(val year: Int, val month: Int, val dayOfMonth: Int) : Comparable<MyDate> {
    override fun compareTo(other: MyDate): Int {
        return when {
            year != other.year -> year.compareTo(other.year)
            month != other.month -> month.compareTo(other.month)
            else -> dayOfMonth.compareTo(other.dayOfMonth)
        }
    }
}

And using it in a range:

fun `a range of objects implementing Comparable can be used as a range`() {
    val testDates = MyDate(1955, 3, 25)..MyDate(1955, 3, 28)

    testDates.start shouldBe MyDate(1955, 3, 25)
    testDates.endInclusive shouldBe MyDate(1955, 3, 28)

    val restDay = MyDate(1955, 3, 27)
    (restDay in testDates) shouldBe true
}

(26 bonus points for anyone who can work out what that date range is. There is a clue in there. And I doubt anyone who is not an NZ cricket fan has a hope in hell of getting it).

It looks like making an iterable range of MyDate objects isn't that hard (esp if I get Copilot to do a lot of it). First I need to implement a "next" method in MyDate:

data class MyDate(val year: Int, val month: Int, val dayOfMonth: Int) : Comparable<MyDate> {
    private val date = LocalDate.of(year, month, dayOfMonth)

    override fun compareTo(other: MyDate): Int {
        return when {
            year != other.year -> year.compareTo(other.year)
            month != other.month -> month.compareTo(other.month)
            else -> dayOfMonth.compareTo(other.dayOfMonth)
        }
    }

    fun nextDay(): MyDate {
        val nextDate = date.plusDays(1)
        return MyDate(nextDate.year, nextDate.monthValue, nextDate.dayOfMonth)
    }
}

Then I need to create the range class:

class MyDateRange(override val start: MyDate, override val endInclusive: MyDate) : ClosedRange<MyDate>, Iterable<MyDate> {
    override fun iterator(): Iterator<MyDate> {
        return object : Iterator<MyDate> {
            var current = start

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

            override fun next(): MyDate {
                val result = current
                current = current.nextDay()
                return result
            }
        }
    }
}

Copilot wrote all of that for me, once I gave it the class name, and then asked it to implement Iterable as well.

I have to admit I kinda spiked this code to see how hard it would be to implement before deciding whether to even include this in the article, so I back-filled the test on this one.

fun `an iterable range can be made by implementing Comparable and Iterable`() {
    val testDates = MyDateRange(MyDate(1955, 3, 25), MyDate(1955, 3, 28))

    (MyDate(1955, 3, 27) in testDates) shouldBe true
    (MyDate(1955, 3, 29) in testDates) shouldBe false

    var list = mutableListOf<MyDate>()

    list = testDates.fold(list) { acc, date -> acc.apply { add(date) } }

    list shouldBe listOf(
        MyDate(1955, 3, 25),
        MyDate(1955, 3, 26),
        MyDate(1955, 3, 27),
        MyDate(1955, 3, 28)
    )
}

This is a good start for me to work out how to make a fully-functioning enum range. Later.

And that is a chunk of code, and not much text. I think the tests largely speak for themselves; the only tricky bit was that last MyDateRange thing, and I'll come back to that. Lemme know if this strategy of using "tests as narrative" works? Cheers.

The code is here: RangesTest.kt.

Righto.

--
Adam