Kotlin and Android #3 — Know your properties

    Kotlin and Android #3 — Know your properties
    Cover image: “Neist Point at sunset — Isle of Skye” by Edoardo Brotto — on flickr

    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.


    Sebastiano Poggi

    Sebastiano Poggi

    “It depends” 🤷‍♂️ — Google Developer Expert for Android, Flutter and Identity. A geek 🤓 who has a serious thing for good design ✨ and for emojis 🤟working at JetBrains (opinions my own)

    London and elsewhere https://sebastiano.dev