Why List.indexOf accepts nullable types?

Why does a List of non-nullable elements accepts a nullable object in indexOf? It seems to aways return -1, which makes sense, since null is not in the list. But if I try to pass null (instead of a variable that points to null) the code does not compiles.

The documentation also seems to imply that this function should not accept a nullable value: docs. Maybe it has something to do with that @UnsafeVariance annotation?

In short, why does this code compiles?

fun main() {
    val list: List<String> = listOf("a", "b", "c")
    val element: String? = null
    val elementIndex: Int = list.indexOf(element)
    println(elementIndex)
}

But this doesn’t?

fun main() {
    val list: List<String> = listOf("a", "b", "c")
    val elementIndex: Int = list.indexOf(null)
    println(elementIndex)
}

Why is that? What if this nullable string is actually "a", so it exists in the list? Explicit null (formally: Nothing? type) is a different story though - it can’t exist in a list of non-nullable types.

I get that it is convinient to accept nullable types, but what I’m trying to understand is why does this code compiles. Because when I read the function signature on the documentation it looks like it should not accept it.

This is the function signature:

abstract fun indexOf(element: @UnsafeVariance E): Int

If E is String, it should not accept String?, or should it?

Also, if I try to define some class with a similar definition, it doesn’t compile:

fun main() {
    val myList: MyList<String> = MyList(listOf("a", "b", "c"))
    val element: String? = null
    println(myList.indexOf(element))
}

class MyList<out E>(private val innerList: List<E>) {
    fun indexOf(element: @UnsafeVariance E): Int {
        return innerList.indexOf(element)
    }
}

I get the error "Type mismatch: inferred type is String? but String was expected", which is what I expected to happen with the standard List.

Is there some special thing that List uses that makes this possible?

It turns out the member function List.indexOf isn’t the only one: (source)

/**
 * Returns first index of [element], or -1 if the list does not contain element.
 */
@Suppress("EXTENSION_SHADOWED_BY_MEMBER") // false warning, extension takes precedence in some cases
public fun <@kotlin.internal.OnlyInputTypes T> List<T>.indexOf(element: T): Int {
    return indexOf(element)
}

I thought the @OnlyInputTypes annotation is doing the magic here, but it turns out this works even without it. However, it turns out that magic annotation is responsible for not allowing Nothing?:

fun main() {
    val myList: MyList<String> = MyList(listOf("a", "b", "c"))
    val element: String? = null
    println(myList.indexOf(element))
    println(myList.indexOf(null))
    println(myList.indexOf("b"))
}

class MyList<out E>(private val innerList: List<E>) {
    fun indexOf(element: @UnsafeVariance E): Int {
        return innerList.indexOf(element)
    }
}

fun <T> MyList<T>.indexOf(element: T): Int {
    println("extension called")
    return indexOf(element)
}

So either the special treatment of nullability was done on purpose here, or maybe there’s something I’m not understanding (I think it has to do with String and String? clearly sharing a type in common, while String and Nothing? don’t).
EDIT: further investigation shows that this fails even with an Int? or an Int, but succeeds with a CharSequence, so this suggests that OnlyInputTypes allows upcasting to either one of the 2 mentioned types (i.e the type of the list and the type of the element) but doesn’t allow upcasting to a common superclass.

1 Like

The idea here is to allow searching for either a supertype or the subtype of the collection type, but do not allow any other type. Because it makes sense to search a list of animals for a cat, it makes sense to search a list of cats for an animal, but it doesn’t make sense to search a list of cats for a dog. It is exactly the same in your case, we just need to remember a nullable type is a supertype of the not null type.

Ok, but how does it work exactly?

Please note here we don’t use the indexOf method of the List, but we use an extension provided by Kotlin. animals.indexOf(cat) uses a member function, but cats.indexOf(animal) uses an extension function of the same name.

Generic functions work differently than methods of generic classes. T is not bound in any way to the type of the list. It can be anything. If you create your own function similar to indexOf, you will notice you can use it with cats.indexOf(animal). In that case T becomes Animal and the list is upcasted to List<Animal>. I believe Kotlin authors added that extension function specifically to allow searching for supertypes.

However, there is a big problem with this approach. As said above, T can be anything - literally. If we do: cats.indexOf(dog), it will set T to Animal and upcast both arguments. We can even do: integers.indexOf("hello") and this will also compile - T will be simply Any. Fortunately, the @OnlyInputTypes annotation mentioned by @kyay10 comes to rescue. That annotation enforces that the T has to be explicitly provided by at least one of arguments, it can’t be just any type. By bounding T to either the collection type or the needle, we effectively allow only subtypes or supertypes, but not siblings or just any mix of types.

2 Likes

Thanks so much @broot and @kyay10, this will help me further investigate and understand this :slight_smile:

1 Like