A Deep Dive into the KotlinVersion Class

Getting the current Kotlin version is easy, but the actual KotlinVersion class is much more interesting. This post shows how to get the Kotlin version programmatically, but then looks at the details of the KotlinVersion class, including how it demonstrates a great way to write an equals method and more.

Note that this demo is part of my new book, Kotlin Cookbook, from O’Reilly Media.

Book cover for Kotlin Cookbook
The Kotlin Cookbook, coming this Fall from O’Reilly Media

Also, I was lucky enough to be interviewed by Hadi Harriri on his Talking Kotlin podcast, and this class came up. That episode has not yet been released, but should be out soon depending on his current backlog.

To start, here is the trivial one-liner to find out which version of Kotlin is executing your code:

fun main() {
    println("The current Kotlin version is ${KotlinVersion.CURRENT}")
}

At the time of this writing, the current release version of Kotlin is 1.3.50, and when you run this script on that version that’s what you get.

Where life gets interesting is when you look at how the KotlinVersion class is implemented. The next series of snippets will examine that in some detail. The KotlinVersion class in the standard library is in the kotlin package, and begins:

public class KotlinVersion(val major: Int, 
                           val minor: Int, 
                           val patch: Int
) : Comparable<KotlinVersion> {

The class is marked public, which isn’t necessary (public is the default) but is typical of library classes. Then follows the primary constructor, which takes three integer values, labeled major, minor, and patch. After the colon after the signature shows that the class implements the Comparable interface for instances of itself, which is already pretty interesting. The Comparable interface in Kotlin is just like its counterpart in Java. It establishes a “natural ordering” on instances of the class.

The Comparable interface in the standard library consists of:

public interface Comparable<in T> {
    public operator fun compareTo(other: T): Int
}

Again, the word public is not necessary either on the class or the function, but doesn’t hurt anything. The compareTo function is labeled an operator function. In this case, the function is used whenever you use <, >, ==, or one of the combination comparison functions, <=, >=, or !=. The function returns an integer whose value doesn’t matter, but should be negative, zero, or positive if the current object is less than, equal to, or greater than its argument.

Returning to the KotlinVersion class, the implementation of compareTo is:

override fun compareTo(other: KotlinVersion): Int = version - other.version

This assumes that the KotlinVersion class has a version property. The primary constructor didn’t show one, so the implementation provides a private property of that name and the function to compute it:

private val version = versionOf(major, minor, patch)

private fun versionOf(major: Int, minor: Int, patch: Int): Int {
    require(major in 0..MAX_COMPONENT_VALUE && 
            minor in 0..MAX_COMPONENT_VALUE && 
            patch in 0..MAX_COMPONENT_VALUE) {
        "Version components are out of range: $major.$minor.$patch"
     }
     return major.shl(16) + minor.shl(8) + patch
}

So the version property is computed from the major, minor, and patch values. The require statement is a pre-condition that verifies the individual values fall within the required range. For all three, the minimum is zero and the maximum is MAX_COMPONENT_VALUE. Like most constants, MAX_COMPONENT_VALUE is found in the companion object:

companion object {
    public const val MAX_COMPONENT_VALUE = 255

    @kotlin.jvm.JvmField
    public val CURRENT: KotlinVersion = KotlinVersion(1, 3, 50)
}

Note the use of both const and val together. From a Java perspective, the properties inside the companion object are effectively static, and the val keyword indicates they’re both final as well. The const modifier means the value is a compile-time constant, rather than being specified at runtime. The max value is therefore hard-wired to 255, and on this version of Kotlin the CURRENT value is an instance of the KotlinVersion class where major, minor, and patch values are 1, 3, and 50, respectively.

That takes care of the require block in the versionOf function. What about the actual value it returns? That’s computed using the shift-left (or left-shift, but the other way reads better) operator function, shl. That is an infix function that shifts the current value by the specified number of bits. Its signature is given by:

public final infix fun shl(
    bitCount: Int
): Int

This is an infix function, so the idiomatic way to invoke it would be major shl 16, minor shl 8, etc, but the form used here works anyway. Basically, the function shifts by two bytes for major and one byte for minor, which gives them enough of an offset that it is very unlikely a different set of major/minor/patch values will result in the same version. For the record, using 1, 3, and 50 gives 65,536 for 1 shl 16 and 768 for 3 shl 8, and the sum from the versionOf function is 66,354:

@Test
fun `left-shift for major, minor, and patch of 1, 3, and 50`() {
    assertEquals(65536, 1 shl 16)
    assertEquals(768, 3 shl 8)
    assertEquals(66354, (1 shl 16) + (3 shl 8) + 50)
}

The major, minor, and patch values are therefore combined into a single integer, which is used for the ordering. The next interesting part is how the KotlinVersion class implements the standard overrides of toString, equals, and hashCode:

override fun toString(): String = "$major.$minor.$patch"

override fun equals(other: Any?): Boolean {
    if (this === other) return true
    val otherVersion = (other as? KotlinVersion) ?: return false
    return this.version == otherVersion.version
}

override fun hashCode(): Int = version

There’s nothing terribly surprising about the overrides of toString or hashCode. The former is just formatting, and the latter reuses that version calculation just discussed, which is pretty convenient since the left-shifted offset mechanism described above is exactly how Josh Bloch recommended creating a decent hashCode function in his Effective Java book for the last twenty-some-odd years. 🙂

The real fun is in the override of the equals function. The KotlinVersion class, like all Kotlin classes, extends Any. As a reminder, the Any class looks like:

package kotlin

public open class Any {
    public open operator fun equals(other: Any?): Boolean
    public open fun hashCode(): Int
    public open fun toString(): String
}

Returning to the implementation of equals in KotlinVersion, the first check uses the triple equals operator === to see if the current reference and the argument are both pointing to the same instance. If so, the result is equal and no further checking is necessary.

If the two objects are different, the code needs to check the version property. Because the argument to the equals function is nullable, you can’t simply access the version property without checking for null first. The safe cast operator, as?, is used to check that. If the argument is not null, this casts it to an instance of KotlinVersion. If the argument is null, the safe cast operator returns null, so the Elvis operator, ?:, is used to simply return false rather than continue.

That’s really interesting, actually. The “return false” statement aborts the assignment of the otherVersion property. The results is Nothing, a class almost guaranteed to confuse Java developers, but beyond the scope of this discussion. Suffice it to say that because Nothing is a subclass of every other class, the resulting type of otherVersion is KotlinVersion, as desired.

Assuming we make it to the last line in the equals method, now there is a value for otherVersion and a value for the current version. The comparison then checks version == otherVersion.version for equality (since they’re both Int values) and returns the result.

That’s quite a lot of power for three lines of code, and is a great example of how to implement an equivalence check for a nullable property (which happens to be another recipe in the book).

To complete the story, the class has a secondary constructor that can be used when the patch value is unknown.

public constructor(major: Int, minor: Int) : this(major, minor, 0)

Then there are two overloads of the isAtLeast function:

public fun isAtLeast(major: Int, minor: Int): Boolean =
    this.major > major || 
        (this.major == major && this.minor >= minor)

public fun isAtLeast(major: Int, minor: Int, patch: Int): Boolean =
    this.major > major || 
        (this.major == major &&
            (this.minor > minor || 
                 this.minor == minor && this.patch >= patch))

A couple of tests show how the basic comparisons work:

@Test
fun `comparison of KotlinVersion instances work`() {
    val v12 = KotlinVersion(major = 1, minor = 2)
    val v1341 = KotlinVersion(1, 3, 41)
    assertAll(
        { assertTrue(v12 < KotlinVersion.CURRENT) },
        { assertTrue(v1341 <= KotlinVersion.CURRENT) },
        { assertEquals(KotlinVersion(1, 3, 41),
            KotlinVersion(major = 1, minor = 3, patch = 41)) }
    )
}

@Test
fun `versions are Ints less than max`() {
    val max = KotlinVersion.MAX_COMPONENT_VALUE
    assertAll(
        { assertTrue(KotlinVersion.CURRENT.major < max) },
        { assertTrue(KotlinVersion.CURRENT.minor < max) },
        { assertTrue(KotlinVersion.CURRENT.patch < max) }
    )
}

Because the KotlinVersion class implements Comparable, it can be used in a range and has a contains method. In other words, you can write:

@Test
fun `check current version inside range`() {
    assertTrue(KotlinVersion.CURRENT in 
        KotlinVersion(1,2)..KotlinVersion(1,4))
}

What you can not do, however, is to iterate over that range, because ranges are not progressions, but that’s a post for another day.

I hope you enjoyed this deep dive into what is arguably a trivial, but highly instructive, class in the Kotlin library. The GitHub repository for the book is located here and contains this example as well as many, many others.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.