Everything else is just a tool to model your domain in a way that’s easier to read and reason about, and to produce much shorter code. Interfaces define module boundaries and can be mocked to enable simpler testing. Through classes, objects, and data structures, you can model your domain entities. Enums make a list of limited options crystal clear. Extensions allow you to add functionality to existing types, without having access to their original source code, giving advantage of better logic structure. With generics, you can write universal code, promoting reuse. And overloaded operators give that extra edge to common operations that benefit from succinct, crisp syntax.
Generics
Generics let you write more general code that is not tied to specific types and is much more reusable, avoiding copy-pasting in many situations. If you have code that is applicable to more than one data type, you can make that type into a generic parameter and use it to write your logic.
Variance
Kotlin supports covariance and contravariance.
Arguments for generic type parameters marked as in (input) pass for any narrower type, too. In the example below, Number suffices in place of Byte, as well as Int, Float, or any other number. Logically, since the Serializer is implemented to receive any Number, any sub-type will fit also.
Arguments for generic type parameters marked as out (output) pass for any wider type. In the example below, a Serializer with output type String also satisfies a requirement for a Serializer with output type Any. Logically, if Any output is expected, it may as well be a String or any other sub-type.
class Serializer<in Item, out Output>(
private val packer: (Collection<Item>) -> Output
) {
private val storage = mutableSetOf<Item>()
fun add(item: Item) {
storage.add(item)
}
fun pack(): Output {
val result = packer(storage)
storage.clear()
return result
}
}
val serializer = Serializer<Number, String> {
it.joinToString(separator = ";")
}
fun packBytes(
serializer: Serializer<Byte, String>,
vararg bytes: Byte
): String {
bytes.forEach { serializer.add(it) }
return serializer.pack()
}
packBytes(serializer, 1, 2, 3)
fun comparePackedNumbers(serializer: Serializer<Number, Any>): Boolean {
serializer.add(1)
serializer.add(2.2)
serializer.add(3L)
return serializer.pack() == "1;2.2;3"
}
comparePackedNumbers(serializer)
Use-site variance
Sometimes it’s not possible to support variance on the type with generic parameters, because its parameter is both input and output. You may still have uses when you only read from or write to the type, so you can indicate variance at the use-site.
out = read, in = write.
in argument accepts any wider type (a super-type can hold a sub-type) and out argument accepts any narrower type (we only deal with the super-type at the use-site, so any sub-type will do).
fun transferPositive(
from: ArrayDeque<out Number>,
to: MutableCollection<in Number>
) {
while (from.isNotEmpty()) {
val element = from.removeLast()
if (element.toInt() > 0) {
to.add(element)
}
}
}
val bufferedNumbers = ArrayDeque<Double>(listOf(-1.0, 0.0, 1.0))
val positiveResult = mutableListOf<Any>("Yay", "🙂", 10)
transferPositive(bufferedNumbers, to = positiveResult)
Star-projection
Star-projections are useful when you don’t want to specify the generic type argument, but still want to use the parametrized type. Usually some general functionality of it that works with every compatible argument type. Reading values from a star-projection is generally safe. Writing is generally not allowed.
fun Collection<*>.printCompact(): String {
return "[" + joinToString(separator = ",") + "]"
}
mutableSetOf("a", "b", "c").printCompact()