Kotlin dilemma: Extension or Member
I have been using Kotlin for more than a year now. In my team, we all love Kotlin because of it’s many awesome features that enable us to write concise, yet understandable code. That’s why, everybody is really excited to use them in their code. Undoubtedly, these features are a great help to programmers, however overusing them can create some serious issues in your code. In this blog, I will be talking about one such feature of Kotlin: Extensions
This blog tries to analyse why Extension functions is one of the most favourite features of many Kotlin developers. Also, it highlights different problems that may surface due to incorrect use of this feature in your code. In the end, it provides some guidelines for Kotlin programmers when they face this dilemma: Extension or Member.
Why we like extensions?
An extension function allows developer to add a functionality to any class or it’s type without editing it or extending it. Also, dot syntax to invoke such method on the receiver object gives a very OOP like feel.
This feature is a great help in the language like Kotlin where all classes are final by default. Only, due to this feature we can easily define custom methods on library classes and invoke them in OOP style.
Consider, an example below. Here, we define an extension function on String
class which tells us whether it’s a palindrome or not.
fun String.isPalindrome() : Boolean {
//...
}
The extension function isPalindrome()
can be called on any String
object as if it is a member function of the String
class.
val str = "aabbaa"val result = str.isPalindrome() //Extension function gets called.assertThat(result).isTrue()
In nutshell, extensions improve readability of the code. Through extensions, we can establish a relationship between an object and a behaviour defined in a some other class than the receiver. Hence, we like to use them in our code but, at sometimes our fondness towards them leads to their overuse.
What happens when we overuse extensions?
Though it’s a great feature, it’s overuse may give rise to some nasty code smells like Feature Envy and Primitive Obsession. Let’s see what are those.
Feature Envy
When a method is more interested in features (properties and methods) of other class than the one in which it is defined.
The extension function appears to work like a member function using the public interface of the receiver class, but it’s actually a static method defined in a different class. This goes against the fundamental rule of thumb to package things together that change together. Excessive use of extension methods can make your code fragile as change in receiver class could break the code in the extension defined on it.
Thus, it is recommended to use member functions over extension functions wherever possible.
Primitive Obsession
Use of primitives (String, Int etc.) instead of value objects.
Consider example below where, we use String
objects to hold an email address. Moreover, we define extension methods to access domain, to check validity etc. instead of defining a value object.
//domain extracts domain from email string
fun String.domain() : String {
//...
}//isValidEmail checks if the string is a valid email
fun String.isValidEmail() : Boolean {
//...
}
And, it works like a charm!
val emailId: String = "foo@bar.com"assertThat(emailId.isValidEmail()).isTrue()
assertThat(emailId.domain()).isEqualTo("bar.com")
Well, an email is a String
but, not every String
is an email. When we use a String
to hold an email, we lose the associated type information such as: it should have a specific format.
Hence, defining a value object Email
which wraps a String
is recommended, rather than using a primitive (String
).
class Email(val address: String) {
init {
require(isValidEmail(address)) {
"Invalid email address"
}
}
val domain: String = ...
companion object {
fun isValidEmail(email: String) : Boolean {
//...
}
}}
Missing domain objects
Consider following example.
fun Collection<Subscription>.totalCost() =
fold(0.0) { a, s -> a + s.cost() }
class Customer {
private val subscriptions = mutableListOf()
fun add(s: Subscription) {
subscriptions.add(s)
}
fun totalCost() = subscriptions.totalCost()
}
The Customer
class defined in the code block above manages a list of Subscription
. Also, it provides functionality to compute totalCost
whose implementation is delegated to an extension function on Collection<Subscription>
. It’s similar to defining a static utility to compute total cost by iterating over a collection of Subscription
.
But, if you squint at it, you can clearly see that, this so called utility is implementing a domain logic as well as it is operating on the data which managed by some other domain object. Also, this extension is very specific to a collection of Subscription
thus, it cannot be used as a general utility. Such, static methods or extensions can be a sign of missing domain object.
In our case, we can rewrite above code by creating a domain object Subscriptions
by putting together the data ( ArrayList<Subscription>
) and the method totalCost
which operates on it in a class
. Use of a domain object to manage the Subscriptions
availed by the Customer
improves overall readability of the code.
//A domain object to model a list of subscriptions availed by a
// Customer
class Subscriptions : ArrayList<Subscription>() {
fun totalCost() = fold(0.0) { a, s -> a + s.cost() }
}
class Customer {
private val subscriptions = Subscriptions()
fun add(s: Subscription) { subscriptions.add(s) }
fun totalCost() = subscriptions.totalCost()
}
Convoluted package dependencies
As calls to extension functions are statically linked so it increases coupling between packages. But, it is a secondary problem and can be avoided if extensions are moved to same package where the receiver class is kept. Then, what is the primary problem?
Handicapped domain object
The public interface of an object defines functionality of the object. Extension methods are not part of the object’s interface. When we use extensions to implement domain logic on the object, the functionality gets scattered across the code base which turns the object into a handicapped domain object whose functionality is dependent on the extension function based prosthetics.
Hence, overuse of extensions could lead to some problems that can seriously hamper maintainability of your code.
Are extensions bad?
Alright, now if you are feeling that extensions are bad and probably we should not use them, then that’s not what I meant. No, extensions are not bad. On the contrary it’s a very useful language feature but, we must use it correctly. And hence, in next few sections I will try to focus on scenarios where it is apt to use extensions. Also, I will speak about times when you should not favour them.
When we should use extensions?
Here are the scenarios where extensions should be used.
inline functions
It is recommended to mark higher order functions as inline to reduce some performance overhead caused due to allocation of lambda objects as well as dynamic dispatch when they are invoked. Member functions cannot be inlined because inlining requires static linking, as it occurs at compile time.
Hence, if the function needs to be inline, use extension.
Nullable receiver
If the receiver can be nullable use extension function because member functions can not be called on nullable variables unless we use safe calls.
Here is very commonly used extension function on String?
objects.
fun CharSequence?.isNullOrBlank(): Boolean {
// ...
}
isNullOrBlank()
can be called on String?
objects without ?.
val str : String? = "I am nullable"assertThat(str.isNullOrBlank()).isFalse() //No safe call required.
Thus, if you need to invoke your function on the nullable variable, use extension.
Non-editable receiver
A classic case of extending functionality of existing library class. Refer the example of isPalindrome()
from above. But, make sure that the function you define must be applicable to all possible instances of the receiver.
So, when receiver class is non-editable, use extension.
A special function overload case
Kotlin treats non-null and nullable type differently thanks to it’s null safety. On the other hand, for Java they are same and sometimes this creates a conflict. Consider an example given below.
class Buffer(...) {
var overflow: String? = null
private set
fun add(str: String) : String {
...
}
}
Above code snippet describes a class Buffer
which provides a method add(String)
that accepts non-null String
objects. Suppose, you also want to support addition of nullable String
objects.
Then, the overloaded function add(String?)
would look like the one given below.
fun add(str: String?): String? = when(str) {
null -> overflow
else -> add(str) // call add with String
}
Unfortunately, this is not allowed in Kotlin because both methods will have same Java method signature.
Thus, either you have to rename the new function to say addNullable
or you can use following trick with the help of extension function add(String?)
.
So, define add(String?)
as an extension function on Buffer
.
fun Buffer.add(str: String?): String? = when(str) {
null -> overflow
else -> add(str) //call goes to the member function
}
Now, you can use both functions on the Buffer
object.
val buf = Buffer()
val str1: String = "I am not nullable"
val str2: String? = "I am nullable"buf.add(str1) //calls member function
buf.add(str2) //calls extension function
This trick comes in handy especially with operator overloads where, using a different name is impossible.
Readability gets improved
Extension functions are basic building block of beautiful DSLs that we see in Kotlin. Also, sometimes in non-DSL scenarios, dot calls make your code easier to understand. In such cases, feel free to use extensions but, make sure that the extensions are limited to the scope in which they are used.
When we should not use extensions?
Here are few scenarios where you should try to avoid extensions.
Function is applicable only in specific context
When an extensions is only applicable to specific instances of the receiver class try not to use them.
Refer example of email handling case above. Though, domain()
can be called on all String
objects, it’s applicable only to ones which contain a valid email address.
Function can become member
When an extension is applicable to all instances of the receiver class and receiver class is owned by you, prefer member over extension. Use of extension in such cases may introduce Feature Envy.
Conclusion
Finally, I think that Extensions is a very useful feature in Kotlin. But, we must use it correctly. Also, it’s recommended to use member functions over extensions wherever possible.
Here is an algorithm, that can be used to determine whether you should use Extension or Member.
For a class C and function f related to it.if (f needs to be open) {
define f as a member function of C.
} else if (f needs to be inline) {
define f as an extension function on C.
} else if (f accesses private members of C) {
define f as a member function of C.
} else if (f is applicable to all objects of class C) {
if ( f should also work with C? ) {
define f as an extension function on C.
make f public.
} else {
if (C is not editable) {
define f as an extension function on C.
make f public.
keep f in common utils package.
} else if (f is a special overload) {
define f as an extension function on C.
make f public.
keep f in same file as that of C.
} else {
define f as a member function of C.
}
}
} else { // f is applicable in specific context
if (f as an extension function improves readability) {
define f as an extension function on C.
keep f in the class where it is used.
} else {
f should be neither extension or nor member.
keep f in the class where it is used.
make f private/internal.
}
}