Search code examples
genericskotlinkotlin-generics

Kotlin "out" and "in" and generics - proper usage


I was trying to make a generic poor-man's data persistence function that would take a MutableSet of data class and serialize it to disk. I'd like something easy for prototyping, and am OK calling "save()" on the set every so often so that if my process gets killed, I can later resume with a "load()" of the saved entries.

But I don't quite get the differences between '*', 'in', 'out', and 'Nothing' even after rereading the Generics page. This SEEMS to work without throwing errors, but I don't get why with them both being "out", I thought one would have to be "in"... or more likely I'm understanding Kotlin Generics completely wrong. Is there a correct way of doing this?

/** Save/load any MutableSet<Serializable> */
fun MutableSet<out Serializable>.save(fileName:String="persist_${javaClass.simpleName}.ser") {
    val tmpFile = File.createTempFile(fileName, ".tmp")
    ObjectOutputStream(GZIPOutputStream(FileOutputStream(tmpFile))).use {
        println("Persisting collection with ${this.size} entries.")
        it.writeObject(this)
    }
    Files.move(Paths.get(tmpFile), Paths.get(fileName), StandardCopyOption.REPLACE_EXISTING)
}

fun MutableSet<out Serializable>.load(fileName:String="persist_${javaClass.simpleName}.ser") {
    if (File(fileName).canRead()) {
        ObjectInputStream(GZIPInputStream(FileInputStream(fileName))).use {
            val loaded = it.readObject() as Collection<Nothing>
            println("Loading collection with ${loaded.size} entries.")
            this.addAll(loaded)
        }
    }
} 

data class MyWhatever(val sourceFile: String, val frame: Int) : Serializable

and then be able to kick off any app with

val mySet = mutableSetOf<MyWhatever>()
mySet.load()

Solution

  • Your code contains an unchecked cast as Collection<Nothing>.

    Making an unchecked cast is a way to tell the compiler that you know more about the types than it does, allowing you to violate some restrictions, including those introduced by the generics variance.

    If you remove the unchecked cast and leave only the checked part of it, i.e.

    val loaded = it.readObject() as Collection<*> 
    

    The compiler won't allow you to add the items in the this.addAll(loaded) line. Basically, the unchecked cast that you made is a dirty hack, because the Nothing type has no real values in Kotlin, and you should not pretend it does. It works only because the MutableSet<out Serializable> at the same time means MutableSet<in Nothing> (meaning that the actual type argument is erased -- it can be any subtype of Serializable -- and since it's unknown what is exactly the items type of the set, there's nothing you can safely put into the set).

    One of the type-safe ways to implement the second function is:

    fun MutableSet<in Serializable>.load(
        fileName: String = "persist_${javaClass.simpleName}.ser"
    ) {
        if (File(fileName).canRead()) {
            ObjectInputStream(GZIPInputStream(FileInputStream(fileName))).use {
                val loaded = it.readObject() as Collection<*>
                println("Loading collection with ${loaded.size} entries.")
                this.addAll(loaded.filterIsInstance<Serializable>())
            }
        }
    }
    

    If you want to make it work with sets that hold more concrete items than Serializable or Any, you can do it with reified type parameters. This makes the compiler inline the declared/inferred type at the load call-sites, so that the type is propagated to filterIsInstance and the items are correctly checked:

    inline fun <reified T> MutableSet<in T>.load(
        fileName: String = "persist_${javaClass.simpleName}.ser"
    ) {
        if (File(fileName).canRead()) {
            ObjectInputStream(GZIPInputStream(FileInputStream(fileName))).use {
                val loaded = it.readObject() as Collection<*>
                println("Loading collection with ${loaded.size} entries.")
                this.addAll(loaded.filterIsInstance<T>())
            }
        }
    }
    

    Or check the items in another way that fits you better. E.g. loaded.forEach { if (it !is T) throw IllegalArgumentException() } before the addAll line.