tl;dr Kotlin properties are awesome and super powerful, but each form comes with a bunch of gotchas. Make sure you fully understand them before deciding what type of property you use!
Kotlin boasts an excellent support for properties. No more you are limited to the bare-bones fields that Java offered; no more envying C# developers for their fully-fledged properties!
Just look at this non-exhaustive list of ways to declare a property:
// 1. A simple field-backed read-only property
val iceCream = IceCream(Flavors.PISTACCHIO)
// 2. A simple field-backed read-write property
var iceCream = IceCream(Flavors.PISTACCHIO)
// 3. A read-only property with a custom getter
val iceCream: IceCream
get() = IceCream(Flavors.PISTACCHIO)
// 4. A read-write property with a custom setter and backing field
var iceCream = IceCream(Flavor.PISTACCHIO)
set(value) {
when (value.flavor) {
Flavor.PISTACCHIO -> print("Excellent choice!")
Flavor.PISTACHIO -> {
print("Pistacchio is spelled with two C's")
throw IllegalArgumentException("You need to learn your Italian")
}
Flavor.COCONUT -> print("Ewww")
}
field = value
}
// 5. A read-only property with a delegate
val iceCream: IceCream by lazy { IceCream(Flavors.PISTACCHIO) }
// 6. A lateinit var
lateinit var iceCream: IceCream
But what if I told you that there’s some non-trivial implications that depend on the way a property is declared?
1-2. Simple vals and vars
The simplest and most common way to declare a property is to have a val or var in a field-like fashion:
// 1. A simple field-backed read-only property
val iceCream = IceCream(Flavors.PISTACCHIO)
// 2. A simple field-backed read-write property
var iceCream = IceCream(Flavors.PISTACCHIO)
The first example is a read-only property whose value is a constant pistacchio-flavoured IceCream
instance. The second example is a read-write property, initialised with another pistacchio-flavoured IceCream
. The property values are initialized at the container’s init time for a class property, or whenever the backing static class is first accessed for a top-level property.
Note that vals are not immutable. This is a common misconception; vals are read-only properties in the sense that their reference cannot be changed once they’re assigned, but nothing prevents their value from mutating internally.
For example, a private val items = mutableListOf(...)
will always point to the same instance of MutableList<>
, but you can change the contents of that list at your heart’s content. This goes for any instance of a class or object that contains mutable state of any kind.
A simple property like these in a class is the closest to a field in Java. The biggest difference is that you can have top-level properties in Kotlin, but no top-level fields in Java.
3. Custom getters
// 3. A read-only property with a custom getter
val iceCream: IceCream
get() = IceCream(Flavors.PISTACCHIO)
A property can have a custom getter. This effectively makes accessing this property like invoking a function, since the getter is a function without arguments.
The not-so-obvious implication is that the value you obtain from the property in this example is a new instance every time. If IceCream
does not have equals and hashcode functions, you might be in for some subtle and extremely annoying bugs where two seemingly identical instances obtained from the property are not equal since they are compared by reference:
class IceCream(val flavor: Flavor)
class FavoriteIceCreamProvider: IceCreamProvider {
val iceCream: IceCream
get() = IceCream(Flavor.PISTACCHIO)
fun isFavorite(otherIceCream: IceCream) = iceCream == otherIceCream
}
private fun checkFavorites(iceCreamProvider: FavoriteIceCreamProvider): Boolean {
val favoriteIceCream = iceCreamProvider.iceCream
return iceCreamProvider.isFavorite(favoriteIceCream) // Returns false!
}
On the other hand, you can exploit this “always fresh” property of custom getters for conveniently accessing something that is not yet available at init time, such as anything Context
-related in an activity:
class MyActivity {
private val itemId: String
get() = intent!!.getStringExtra("itemId")
// ...
}
This is particularly useful when handling things that can change over time, like an activity’s intent: when onNewIntent()
is called, you can update the intent field value with the new one. A Lazy
property wouldn’t reflect the change as they can only be assigned a value once, so a custom getter will be a better fit. Obviously you need to ensure that these properties are only accessed when their backing value is present, or your app will crash — just like for lateinit
and Lazy
.
Lastly, make sure you don’t use custom getters to mask expensive operations behind a property, as that’s not the expected behaviour of a property. Prefer functions instead, in that case.
4. Custom setter with backing field
If you want to customize the assignment logic, you can use a property with a custom setter and the backing field:
var iceCream = IceCream(Flavor.PISTACCHIO)
set(value) {
when (value.flavor) {
Flavor.PISTACCHIO -> print("Excellent choice!")
Flavor.PISTACHIO -> {
print("Pistacchio is spelled with two ‘C’s")
throw IllegalArgumentException("You need to learn your Italian")
}
Flavor.COCONUT -> print("Ewww")
}
field = value
}
You could technically do without assigning the backing field, but I cannot think of a scenario in which it would make sense to have a custom setter and not assigning the backing field.
This example shows one of the possible usages of custom setters, executing some code before saving the new value. A common case is to do some validation against the new value, but sometimes you’ll see a custom setter used to trigger some side-effect; in this case, respectively refusing misspelled pistacchio, and logging.
In general I’d advise against having custom setters to trigger side-effects; rather, use a function and set the value from that:
private var _iceCream = IceCream(Flavors.PISTACCHIO)
val iceCream: IceCream
get() = _iceCream
fun setIceCream(value: IceCream) {
when(value.flavor) {
Flavors.PISTACCHIO -> print("Excellent choice!")
Flavors.PISTACHIO -> {
print("Pistacchio is spelled with two C's")
throw IllegalArgumentException("You need to learn your Italian")
}
Flavors.COCONUT -> print("Ewww")
}
_iceCream = value
}
It’s clearer and provides a better indication that it’s not a trivial field-like assignment to users of the property, which would otherwise have no way to know it.
5–6. Property delegates and lateinit
Since there’s a whole article on the topic in this series, I’m going to keep it short here. We’ll consider the Lazy
delegate as it’s the most common; other delegates can have different gotchas.
// 5. A read-only property with a delegate
val iceCream: IceCream by lazy { IceCream(Flavors.PISTACCHIO) }
// 6. A lateinit var
lateinit var iceCream: IceCream
Both lateinit
and Lazy
-delegated properties start without an actual value at init time. lateinit
requires an explicit initialisation before it can be accessed, and since Kotlin 1.2 you can check whether the initialisation has been performed already by using ::iceCream.isInitialized
. Lazy
-delegated properties will automatically lazy-initialise their value on their first access, and there is no way to tell whether they have been assigned a value already or not.
For Lazy
properties, once the value has been initialised, it can’t change; lateinit
properties, being read-write properties, can be reassigned after the initial initialisation. A notable limitation of lateinit
properties is that they can’t have nullable types, nor types backed by primitive types such as Int
, Float
, Double
etc.
Hopefully it’s now clearer when to use, and when not to use, each of the style of properties. If you have other good or bad examples, make sure to let me know, I’d like to grow the collection!
Want to read more?
Take a look at the other articles in this series.