PSA: “Static polymorphism” with extension functions.

I’ve been doing Kotlin for a while, and I have mixed feelings about extension functions as a feature, but recently I’ve taken advantage of a practice where we define several “versions” of an extension functions in the same package with more-or-less specific receiver types so that the compiler can pick the most appropriate one at compile time. I don’t see this aspect of extensions discussed very much, so I figured I would just post it here in case it’s helpful to someone who hadn’t thought of it before. Keep in mind that this is rarely going to be useful and even when it’s applicable, there’s a good chance it falls in the “premature optimization” category. Now, let me explain:

Hopefully we all know that extension function receiver types are resolved statically, which is quite different from how true method calls works. Here’s an example of exactly what that means:

Regular Polymorphism

open class NumberLike(protected val value: Number) { open fun printMessage() { println("I'm a Number!") } } class IntLike(value: Int): NumberLike(value) { override fun printMessage() { println("I'm an Int!") } } val i: IntLike = IntLike(1) i.printMessage() // prints: "I'm an Int!" val n: NumberLike = IntLike(1) n.printMessage() // prints: "I'm an Int!" 

We see that both i and n print the same message, even though n is typed as NumberLike. That’s because the method is “attached” to the object, itself, and the object is an IntLike, regardless of whether the compiler knows it at compile time or not.

With extension functions, things work differently:

Extension Function (No Polymorphism)

fun Number.printMessage() { println("I'm a Number!") } fun Int.printMessage() { println("I'm an Int!") } val i: Int = 1 i.printMessage() // Prints: "I'm an Int!" val n: Number = 1 n.printMessage() // Prints: "I'm a Number!" 

Notice that the two calls now print different messages. This is because extension functions are not real methods attached to the object, they are just regular functions in disguise. So, there are two separate, unrelated, functions compiled here: one with a signature like (Number) -> Unit and one with a signature like (Int) -> Unit. When the compiler is deciding which one to call, it can only go by the variable binding‘s known type at compile time. So, even though n is actually an Int, the compiler can’t (generally) know that, so it must call the (Number) -> Unit function.

This is all well-documented, and even though I hate the feeling of inconsistency it adds to the language, it is what it is. But, there’s something else at work here that’s a little bit interesting that I hadn’t given much thought to: The compiler does pick the most specifically typed function it can at compile time. Since i is typed as an Int, it can obviously be used as a Number as well, yes the compiler is smart enough to favor the (Int) -> Unit function instead of the (Number) -> Unit one.

But what happens when there are two options for extension functions, but neither receiver type is a sub-type of the other?

Extensions With Unrelated Receiver Types

interface Foo { } interface Bar { } class FooBar: Foo, Bar fun Foo.printMessage() { println("I'm a Foo!") } fun Bar.printMessage() { println("I'm a Bar!") } val f: Foo = FooBar() f.printMessage() // Prints: "I'm a Foo!" val b: Bar = FooBar() b.printMessage() // Prints: "I'm a Bar!" val fb: FooBar = FooBar() fb.printMessage() // Compile Error: "Overload resolution ambiguity." 

So, that’s not surprising. The compiler can’t figure out which extension function it should call because there’s no reason to prefer one over the other; we’ll have to cast fb to whichever type we want the compiler to use to resolve the extension function it should call.

There are a few places where this technique might make sense. For one hypothetical example, we’ll define a type like Java’s Optional<T> (relax- it’s just an example), called Option<T>:

sealed interface Option<out T> data class Some<out T>(val value: T): Option<T> object None: Option<Nothing> 

To get the value out of an Option, you’d have to do a type check to see if it’s a Some, and then get the value from there. But that can be a little tedious. What if we want to just ask an Option to give us the value if it’s present and give us null if it’s not? Well, we can’t actually do that for all Options, because what if T is already nullable? Then, if your Option gives you a null, you don’t know for sure if the value was “missing” (None) or if the value was an intentionally stored Some(null). So it’s best to define an extension that will only work on Options where T is not nullable:

fun <T: Any> Option<T>.getOrNull(): T? = when (this) { is Some -> value None -> null } 

Now, this example is a stretch, because there’s no reason to call getOrNull after we know that an Option is actually a Some or None, but let’s just pretend because this is getting too long already. If you already know that you have a Some at a call site and you call getOrNull, it’s going to do an unnecessary type check. And you can’t implement getOrNull as a true method with overloads because of the generic type constraint. The only optimization left available to us is static polymorphism:

fun <T: Any> Option<T>.getOrNull(): T? = when (this) { is Some -> value None -> null } fun <T> Some<T>.getOrNull(): T = value // technically we don't even need to constrain T to be non-nullable here because if you know it's a Some, then getting a null just means it was Some(null) fun None.getOrNull(): Nothing? = null // don't need a generic at all. And using Nothing is advisable here because Nothing is a subtype of everything. 

So, you get two benefits here if you already proved to the compiler that your Option is one of its variants:

  1. You skip a redundant type check
  2. You get more precise return types. E.g., if you have Option<Int>, then getOrNull() returns Int?, but if you already proved to the compiler that it’s a Some<Int>, then getOrNull() returns Int.

I hope that was interesting for someone, because I spent more time typing it than I thought I would. 🙂

Cheers

submitted by /u/ragnese
[link] [comments]