article banner

Effective Kotlin Item 33: Consider a primary constructor with named optional arguments

This is a chapter from the book Effective Kotlin. You can find it on LeanPub or Amazon.

When we define an object and specify how it can be created, the most popular option is to use the primary constructor:

class User(var name: String, var surname: String) val user = User("Marcin", "Moskała")

Not only are primary constructors very convenient, but in most cases it is actually very good practice to build objects using them. It is common that we need to pass arguments that determine an object’s initial state, as illustrated by the following examples, starting from the most obvious one: data model objects that represent data1. For such an object, its whole state is initialized using a constructor and then held as properties.

data class Student( val name: String, val surname: String, val age: Int )

Here’s another common example in which we create a presenter2 for displaying a sequence of indexed quotes. We inject dependencies into this object using a primary constructor3:

class QuotationPresenter( private val view: QuotationView, private val repo: QuotationRepository ) { private var nextQuoteId = -1 fun onStart() { onNext() } fun onNext() { nextQuoteId = (nextQuoteId + 1) % repo.quotesNumber val quote = repo.getQuote(nextQuoteId) view.showQuote(quote) } }

Note that QuotationPresenter has more properties than those declared in the primary constructor. Here, nextQuoteId is a property that is always initialized with the value -1. This is perfectly fine, especially when the initial state is set up using default values or primary constructor parameters.

To better understand why the primary constructor is such a good choice in the majority of cases, we must first consider common Java patterns related to the use of constructors:

  • the telescoping constructor pattern
  • the builder pattern

We will see the problems that these solve and the better alternatives that Kotlin offers.

Telescoping constructor pattern

The telescoping constructor pattern is nothing more than a set of constructors for different possible sets of arguments:

class Pizza { val size: String val cheese: Int val olives: Int val bacon: Int constructor( size: String, cheese: Int, olives: Int, bacon: Int ) { this.size = size this.cheese = cheese this.olives = olives this.bacon = bacon } constructor( size: String, cheese: Int, olives: Int ) : this(size, cheese, olives, 0) constructor( size: String, cheese: Int ) : this(size, cheese, 0) constructor(size: String) : this(size, 0) }

Well, this code doesn't really make any sense in Kotlin because we can use default arguments instead:

class Pizza( val size: String, val cheese: Int = 0, val olives: Int = 0, val bacon: Int = 0 )

Default values are not only cleaner and shorter, but their usage is also more powerful than the telescoping constructor. We can specify just size and olives without mentioning other parameters:

val myFavorite = Pizza("L", olives = 3)

We can also add another named argument either before or after olives:

val myFavorite = Pizza("L", olives = 3, cheese = 1)

As you can see, default arguments are more powerful than the telescoping constructor because:

  • We can define a subset of parameters with the default arguments we want.
  • We can provide arguments in any order.
  • We can explicitly name arguments to make it clear what each value means.

The last reason is quite important. Think of the following object creation:

val villagePizza = Pizza("L", 1, 2, 3)

It is short, but is it clear? I bet that even the person who declared the pizza class won’t remember the positions of the bacon and cheese parameters. Sure, in an IDE we can see an explanation, but what about those who just scan code or read it on Github? When arguments are unclear, we should explicitly state what their names are using named arguments:

val villagePizza = Pizza( size = "L", cheese = 1, olives = 2, bacon = 3 )

As you can see, constructors with default arguments surpass the telescoping constructor pattern. However, there are more popular construction patterns in Java, one of which is the Builder Pattern.

Builder pattern

Named parameters and default arguments are not allowed in Java. This is the main reason why Java developers use the builder pattern, which allows them to:

  • name parameters,
  • specify parameters in any order,
  • have default values.

Here is an example of a builder defined in Kotlin:

class Pizza private constructor( val size: String, val cheese: Int, val olives: Int, val bacon: Int ) { class Builder(private val size: String) { private var cheese: Int = 0 private var olives: Int = 0 private var bacon: Int = 0 fun setCheese(value: Int): Builder { cheese = value return this } fun setOlives(value: Int): Builder { olives = value return this } fun setBacon(value: Int): Builder { bacon = value return this } fun build() = Pizza(size, cheese, olives, bacon) } }

With the builder pattern, we can set these parameters as we want by using their names:

val myFavorite = Pizza.Builder("L").setOlives(3).build() val villagePizza = Pizza.Builder("L") .setCheese(1) .setOlives(2) .setBacon(3) .build()

As we’ve already mentioned, these advantages of Java’s builder pattern are fully satisfied by Kotlin’s named and default arguments:

val villagePizza = Pizza( size = "L", cheese = 1, olives = 2, bacon = 3 )

When comparing these two simple usages, you can see the advantages of named parameters over the builder:

  • Named parameters are shorter — a constructor or factory method with default arguments is much easier to implement than the builder pattern. It is a time-saver both for the developer who implements this code and for those who read it. This is a significant difference because implementation of the builder pattern can be time-consuming. Builder modifications are harder to apply. For instance, changing the name of a parameter requires not only changing the name of the function used to set it but also the name of the parameter in this function, the body of this function, the internal field used to keep it, the parameter name in the private constructor, etc.

  • Named parameters are cleaner — when you want to see how an object is constructed, everything you need is in a single method instead of being scattered around a whole builder class. How are objects held? Do they interact? These are questions that are not so easy to answer when we have a big builder.

  • Named parameters offer simpler usage — the primary constructor is a built-in concept. The builder pattern is an artificial concept and therefore requires some knowledge about it. For instance, a developer can easily forget to call the build function (or, in other cases, create).

  • Named parameters have no problems with concurrence — this is a rare problem, but function parameters are always immutable in Kotlin, while properties in most builders are mutable. Therefore, it is harder to implement a thread-safe build function for a builder.

All this doesn't mean that we should always use a constructor instead of a builder, so let’s look at some cases where the various advantages of the builder pattern shine.

Builders can require a set of values for a name (setPositiveButton, setNegativeButton, and addRoute), and they allow us to aggregate (addRoute):

val dialog = AlertDialog.Builder(context) .setMessage(R.string.fire_missiles) .setPositiveButton(R.string.fire) { d, id -> // FIRE MISSILES! } .setNegativeButton(R.string.cancel) { d, id -> // User cancelled the dialog } .create() val router = Router.Builder() .addRoute(path = "/home", ::showHome) .addRoute(path = "/users", ::showUsers) .build()

To achieve similar behavior with a constructor, we would need to introduce special types to hold more data in a single argument:

val dialog = AlertDialog( context, message = R.string.fire_missiles, positiveButtonDescription = ButtonDescription(R.string.fire) { d, id -> // FIRE MISSILES! }, negativeButtonDescription = ButtonDescription(R.string.cancel) { d, id -> // User cancelled the dialog } ) val router = Router( routes = listOf( Route("/home", ::showHome), Route("/users", ::showUsers) ) )

This notation is generally badly received in the Kotlin community and we tend to prefer a DSL (Domain Specific Language) builder for such cases:

val dialog = context.alert(R.string.fire_missiles) { positiveButton(R.string.fire) { // FIRE MISSILES! } negativeButton { // User cancelled the dialog } } val route = router { "/home" directsTo ::showHome "/users" directsTo ::showUsers }

These kinds of DSL builders are generally preferred over the classic Builder pattern since they give more flexibility and cleaner notation, but it is true that making a DSL is harder. On the other hand, making a builder is already hard. If we decide to invest more time to allow better notation at the cost of a less obvious definition, why not take this one step further? In return, we will have more flexibility and readability. In the next chapter, we talk more about using DSLs for object creation.

Another advantage of the classic Builder pattern is that it can be used as a factory that might be filled partially and passed further. Here is a partially filled example builder that is used to specify a default dialog in our application:

fun Context.makeDefaultDialogBuilder() = AlertDialog.Builder(this) .setIcon(R.drawable.ic_dialog) .setTitle(R.string.dialog_title) .setOnCancelListener { it.cancel() }

The alternatives I find more suitable in Kotlin are either making a default object and using copy to customize its properties, or creating this class using a function with optional parameters.

data class DialogConfig( val icon: Int, val title: Int, val onCancelListener: (() -> Unit)?, //... ) val defaultDialogConfig = DialogConfig( icon = R.drawable.ic_dialog, title = R.string.dialog_title, onCancelListener = { it.cancel() }, //... ) // or fun defaultDialogConfig( val icon: Int = R.drawable.ic_dialog, val title: Int = R.string.dialog_title, val onCancelListener: (() -> Unit)? = { it.cancel() } ) = DialogConfig(icon, title, onCancelListener, /*...*/)

Both options seem to be popular for unit testing. They seem to be a good alternative to the classic Builder pattern, so I don't find the Builder pattern advantageous.

In the end, the classic Builder pattern is rarely the best option in Kotlin. It is sometimes chosen:

  • to make code consistent with libraries written in other languages that used the Builder pattern,
  • when we design an API to be easily used in other languages that do not support default arguments or DSLs.

Otherwise, we generally prefer either a primary constructor with default arguments or an expressive DSL builder.

Summary

Creating objects using a primary constructor is the most appropriate approach for the vast majority of objects in our projects. Telescoping constructor patterns should be treated as obsolete in Kotlin. I recommend using default values instead as they are cleaner, more flexible, and more expressive. The classic builder pattern is rarely a good choice. In simpler cases, we can just use a primary constructor with named arguments; when we need to create a more complex object, we can define a DSL builder.

1:

A data model is not necessarily a data class, and vice versa. The former concept represents classes in our project that represent data, while the latter is special support for such classes. This special support is a set of functions that we might not need or that we might need for classes that do not serve as our data model.

2:

A presenter is a kind of object that is used in the Model View Presenter (MVP) architecture, which used to be popular in Android before its successor MVVM became popular.

3:

Dependency injection is a technique in which an object receives other objects that it depends on. By itself, it does not need any library (or framework) like Koin or Dagger, although I find them useful.