1. Overview

In this tutorial, we’ll look at Kotlin’s Comparable interface. We’ll show its role and intended usage within the language, and then, we’ll provide some hints and “gotchas” about its implementation within user-defined types.

As we will see, the interface resembles its Java counterpart, however with novel implementation opportunities and behavioral extensions that are unique to the Kotlin version.

2. The Comparable Interface

The Kotlin Comparable interface plays a central role in natural order definition over primitive and custom types.

The standard library defines the interface as:

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

By implementing compareTo(), we are introducing a comparison between two instances of T. The Comparable contract states that the sign of the returned Int defines the order relation. According to the interface documentation:

  • A returned zero value signals the Comparable receiver and the other T instance are equal (according to the order relation of the Comparable implementation).
  • Positive values signal the receiver is greater than the other T instance.
  • Negative values signal the receiver is less than the other T instance.

If T implements Comparable<T>, then any two T instances can be compared to each other. In this case, the Comparable interface defines a natural order over the T domain. Since compareTo() is not allowed to return null values, we can also say this natural order is a total order.

2.1. Operator Support

The operator keyword in the compareTo() definition allows relational operators to replace explicit method invocations and assertions on the returned Int value. The expression:

a >= b

can then replace explicit compareTo() usage, as in:

a.compareTo(b) >= 0

Again, Kotlin’s operator overloading improves code readability, especially when comparing user-defined types.

2.2. Equals Consistency

Equals consistency is a soft constraint for Comparable, so any two T instances that are equal for compareTo() can be different for equals(). Kotlin’s equality comparisons identify structural similarities and depend on the companion hashCode() contract, while Comparable is only meant to define a natural order.

It’s common for Comparable implementations to break symmetry with equals(), especially for complex types, as compareTo() implementations often need to consider just a fraction of an object’s internal structure (with Java’s BigDecimal being a common example for this scenario). Still, some data structures may demand equals() consistency, as is the case for both TreeSet and TreeMap.

3. Interface Implementation

We usually implement compareTo() by matching some aspects of the objects being compared. More often than not, we will find ourselves comparing a single criterion, with the compared field being a Comparable instance itself, like a Number or a String.

Let’s use a simple Version class, which has one Int property, value. A correct Comparable implementation would be as simple as:

class Version(val value: Int): Comparable<Version> {
    override fun compareTo(other: Version): Int = value.compareTo(other.value)
}

To improve readability, Kotlin extends Comparable for calling compareTo() in infix form, with the extension simply delegating its calls to the main interface compareTo(). If we prefer, we can then rewrite the previous code as:

class Version(val value: Int): Comparable<Version> {
    override fun compareTo(other: Version): Int = value compareTo other.value
}

3.1. Using Multiple Criteria

Of course, nothing prevents us from expanding the comparison to multiple criteria. Let’s modify our Version class and include a timestamp field. To account for more than one property, the Comparable implementation could be:

class Version(val value: Int, val timestamp: Instant) : Comparable<Version> {
    override fun compareTo(other: Version): Int = when (
        val valueComparison = value.compareTo(other.value)) {
            0 -> timestamp compareTo other.timestamp
            else -> valueComparison
        }
}

According to this logic, the secondary timestamp criterion refines ordering only when the two instances have the same value.

3.2. Chaining Comparison Criteria

We could extend the example to include additional criteria, for there is no intrinsic limit to a custom type’s complexity and, thus, to the complexity of the ordering logic. If the comparison logic simply chains different criteria together, we can provide an alternative implementation using Comparator helpers to rephrase the previous implementation as:

class Version(val value: Int, val timestamp: Instant) : Comparable<Version> {
    override fun compareTo(other: Version): Int = compareBy(
        Version::value,
        Version::timestamp
    ).compare(this, other)
}

As the number of comparison criteria increases, and we start feeling implementation is becoming tricky, we can introduce natural order for a type by using a different strategy and implementing Comparable via delegation.

3.3. Natural Order by Delegation

We all know Kotlin allows implementing interfaces by delegation, with Comparable being no exception. If we implement Comparable by delegation, we’re separating the comparison code from the type definition code itself. The approach makes sense if the comparison logic turns out to be tricky, usually because it’s considering several ordering criteria over a complex type, otherwise the strategy is overkill.

As an example of this approach, let’s consider a reusable comparable class delegate, defined as:

class DelegatedComparable<T>(vararg criteria: Pair<Comparable<*>, Selector<T>>): Comparable<T> {

    @Suppress("UNCHECKED_CAST")
    private val comparator: Comparator<T> = criteria.fold(Comparator { _, _ -> 0 }) { acc, crit ->
        acc.then { _, b -> crit.first as Comparable<Any>).compareTo(crit.second(b)) }
    }
    
    override fun compareTo(other: T): Int = comparator.compare(null, other)
}

In this definition, Selector<T> is simply the functional interface alias:

typealias Selector<T> = (T)-> Comparable<*>

The biggest change from the direct Comparable implementation is that, within the Comparable delegate, the compareTo() method receiver won’t be a type T instance anymore. Still, delegate implementation will need access, somehow, to the chosen comparison criteria.

To solve this problem, DelegatedComparable’s constructor accepts a variable number of mappings between a comparison criterion and a corresponding Selector<T>, a simple helper function used to retrieve the corresponding value from compareTo()‘s other parameter. Given the mappings in its constructor, DelegatedComparable chains the sorting criteria together and uses the resulting Comparator to drive the compareTo() implementation.

So, let’s say we want to add natural order by delegation to our Version class. It’s enough to modify the original class to support delegation:

class Version(val value: Int, val timestamp: Instant) : Comparable<Version> by DelegatedComparable(
    value to Version::value,
    timestamp to Version::timestamp
)

Here, the Comparable implementation is no longer coupled to the type code definition. However, this approach still creates a natural order on T, for inheritance is still binding the Comparable behavior to that type.

4. Implementation Gotchas

If the compareTo() implementation works on numeric fields, we may be tempted to implement the function by subtraction, something like:

class Version(val value: Int): Comparable<Version> {
    override fun compareTo(other: Version): Int = value - other.value
}

Please don’t do this. For some combinations of the value pairs, the type system may surprise you with its overflow handling rules. The Int wraparound behavior, for example, can be very tricky to spot because it happens “automagically”, without throwing any exception. Non-integer data types can also create problems, for we may have to handle positive or negative infinity. The general recommendation is to exploit support for numeric types comparison that Kotlin already provides, along with guidelines similarly provided for Java.

4.1. Comparable Extensions

Being a Comparable type provides some advantages in Kotlin, for several extensions functions enable range creation and value coercion support. We can also think about implementing progressions for our custom Comparable types, say to support looping over ranges as we are used to with primitive types.

5. Collection Sorting with Natural Order

We can sort collections of Comparable types both in ascending and descending natural order via the sorted() and sortedDescending() methods:

val underTest = setOf(Version(7), Version(9), Version(20), Version(1), Version(0))

val ascending = undertTest.sorted()

val descending = underTest.sortedDescending()

We can sort any type of collection, even a Set, as Kotlin defines both sorting methods in the parent Iterable type. Whatever the collection at hand, let’s keep in mind that both sorted() and sortedDescending() will always return a new List, as sorting does not happen in place.

6. Conclusion

In this article, we introduced the Comparable interface, provided hints about its implementation, and discussed its role within the Kotlin language. Technically speaking, Comparable is a simple interface, but its implementation still comes with its twists and gotchas. Being a low-level interface, it also has many ramifications and touchpoints with other relevant parts of the language.

We linked the discussion to a number of relevant articles so that interested readers can continue to improve their knowledge of Kotlin ordering relations, sorting operations, and related implementation techniques.

As usual, the code discussed in this article is available over on GitHub.

Comments are closed on this article!