3 minutes
Avoid Long Apply in Variable Initialisations
The apply scope function is a very useful Kotlin standard library method for grouping long variable configuration blocks, when no builder is available. It can be used when creating functions that return the instance of the object (in a builder like fashion), like in the following code:
class MyClass {
fun func(): MyClass = apply {
// add code here
}
}
Moreover, it is especially convenient when using older APIs which do not provide a builder. For example:
class MyClass {
fun func(person: Person, friendList: List<String>) {
val bunde = Bundle().apply {
putString("name", person.fullName
putInt("age", person.age)
putBoolean("isActive", person.isActive)
putStringList("friends", ArrayList<String>(friendList))
}
}
}
Thus, it provides a means of creating and initialising a complex object in a val
property of a class. In order words, you can do something like this:
class MyClass(person: Person, friendList: List<String>) {
private val myBundle: Bundle = Bundle().apply {
putString("name", person.fullName
putInt("age", person.age)
putBoolean("isActive", person.isActive)
putStringList("friends", ArrayList<String>(friendList))
}
}
However, often in development, when the requirements change and different (although not always) developers update the same piece of code, there is often little attention paid to minor code quality degradation. For example, consider updating the above example with code to add more filtering in the friendsList
:
class MyClass(person: Person, friendList: List<String>) {
private val myBundle: Bundle = Bundle().apply {
putString("name", person.fullName
putInt("age", person.age)
putBoolean("isActive", person.isActive)
putStringList(
"friends",
friendList.filter { " " in it }
.map {
it.split(" ")
.zipWithNext()
.map { pair -> "${pair.second}, ${pair.first}" }
}
.flatten()
.fold(ArrayList<String>()) { acc, item ->
acc.add(item)
acc
}
)
}
}
Then someone might naively attempt to extract the friendsList
filtering into an external method:
class MyClass(person: Person, friendList: List<String>) {
private val myBundle: Bundle = Bundle().apply {
putString("name", person.fullName
putInt("age", person.age)
putBoolean("isActive", person.isActive)
filterFriendsList(friendsList)
}
private fun filterFriendsList(friendList: List<String>) {
myBundle.putStringList(
"friends",
friendList.filter { " " in it }
.map {
it.split(" ")
.zipWithNext()
.map { pair -> "${pair.second}, ${pair.first}" }
}
.flatten()
.fold(ArrayList<String>()) { acc, item ->
acc.add(item)
acc
}
)
}
}
Now this method extraction has introduced a very simple NullPointerException
bug, one which may surprise you somewhat when running the code. It is certainly non-obvious and easily missed, especially if you’ve been on top of the code for a while. Running the above snippet throws a NullPointerException
in the following line:
myBundle.putStringList(
The variable myBundle
is null. Although at first this may be confusing it has a very simple explanation. Let’s focus on this line here:
private val myBundle: Bundle = Bundle().apply {
When a new MyClass
object is created, the myBundle
variable will have to be initialised. This initialisation is achieved by creating a new Bundle
object, then running the apply
block on the newly created object and then taking the result and assigning it to the myBundle
variable. So the code of the filterFriendsList
would work as it was written previously, because it was executing in the context of the newly created Bundle
object, but cannot work as is without the myBundle
instance having been initialised first.
There are multiple solutions to this problem. Do not forget that it was introduced by adding excessive logic in the variable initialisation, inside the apply
block. Therefore the simplest solution is to extract all the initialisation logic in a separate function. For example:
class MyClass(person: Person, friendList: List<String>) {
private val myBundle: Bundle = createMyBundle(person, friendList)
private fun createMyBundle(person: Person, friendList: List<String>): Bundle {
val bundle = Bundle()
bundle.putString("name", person.fullName
bundle.putInt("age", person.age)
bundle.putBoolean("isActive", person.isActive)
bundle.putStringList(
"friends",
friendList.filter { " " in it }
.map {
it.split(" ")
.zipWithNext()
.map { pair -> "${pair.second}, ${pair.first}" }
}
.flatten()
.fold(ArrayList<String>()) { acc, item ->
acc.add(item)
acc
}
)
return person
}
}
Now all the code needed is in a single function, it is clear that it creates a new object instance, and it can be further refactored as needed in the future.