Kotlin dilemma: Extension or Member

Harshad Nawathe
The Startup
Published in
8 min readMay 25, 2020

--

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.

Photo by Caleb Jones on Unsplash

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.
}
}

--

--