Accessing values from an array using subscripts is a light and easy way to fetch list data.
Kotlin brought uniformity in data access across regular arrays and List
s by allowing us to access data from a List
using subscripts.
But, what about our custom data models? Can they have subscripts if they mostly represent a list of data?
This week, we will be discussing how we can add subscript access to our custom data models through operator overloading.
Subscripts are handy
Subscripts or the []
make it possible to have an incredibly lightweight and consistent syntax for accessing elements in an array.
It’s like moving a small window over a list of items to pick one.
In Java, subscripts are not available for data structures such as List
. To access an element from the List
we have to call the get(index: Int)
method.
In Kotlin, however, we can access List
elements the same way we access an array.
val names: List<String> = listOf(
“James Arthur”,
“Brett Eldredge”
)
println(“Artist: ${names[0]}”)
This improved syntax is handy because we don’t have to remember different method names or access strategies for different data structures.
No matter if we have an array or a List
, as long as our naming convention represents the property to be a sequence of data, we can apply the same access method.
Our code becomes robust in the sense that we can easily swap out arrays with List
s and our code will work.
Now the question is, can we build subscript access for our custom data models?
Yes, we can. Before we get into the implementation, let’s see how subscripts work for List
s in Kotlin.
It’s a case of operator overloading
Kotlin supports operator overloading with the keyword operator
. While there are plenty of standard operator overloads available, we will focus on getting the []
access working for our custom class in this article.
A quick look into the List<E>
class of kotlin.collections
shows up an overloaded get(index: Int)
like this:
public operator fun get(index: Int): E
The standard behaviour is that Kotlin transforms get(index: Int)
methods marked with the operator
keyword to []
during access.
With this little syntactic sugar, we can access List
and ArrayList
items with the subscript notation.
Let’s make a mixtape
To understand how and where we can leverage subscripts, we are going to work through an everyday use case, mixtapes.
Popular music apps these days provide personalised lists of tracks for us to listen every day. Some, like YouTube music, call these playlists as mixtapes.
Building on this idea, we can have our custom data model called Mixtape
like this:
data class Mixtape(
val title: String,
val forUser: String,
val tracks: List<Track>
)
operator fun Mixtape.get(index: Int): Track = tracks[index]
val Mixtape.size
get(): Int = tracks.size
Here, we have defined a get(index: Int)
method marked as an operator
to enable subscript access for our class.
For brevity, our Mixtape
here simply returns elements from the tracks list. However, we can have logic inside this get()
method to suit our needs. An example would be returning sorted or filtered tracks.
We have also defined a size
property to add list-like functionality to our Mixtape
class, so that we can use it like this:
for (index in 0 until mixtape.size) {
val track = mixtape[index]
println(“${track.title} - ${track.artist}”)
}
Wondering why we have used extension functions and properties for our Mixtape
instead of having them defined inside the class?
It’s because, with this approach, we can have clean models with logic separated from the data, as we discussed in the clean models article.
How is this helpful?
At a glance, this little technique might not seem to be of much value. However, semantically it’s a win.
Data classes like Mixtape
and Podcasts
indicate they are a list of items. Having a uniform data access strategy for lists in our project helps us skip the appropriate method lookup.
For example, whenever we get a Mixtape
object, we know it’s a list of songs, and we can traverse it like an array. We don’t have to dig into the implementation of Mixtape
to find out how to extract elements from it.
This semantic structuring not only saves us time but also prevents us from writing code like this:
for (index in 0 until mixtape.tracks.size) {
val track = mixtape.tracks[index]
println(“${track.title} - ${track.artist}”)
}
We can go a step further to hide our List<Track>
from external access if we have some business logic inside our Mixtape
s get()
method:
data class Mixtape(
val title: String,
val forUser: String,
private val tracks: List<Track>
) {
operator fun get(index: Int): Track = tracks[index]
val size
get(): Int = tracks.size
}
With this encapsulation, we can ensure that our Mixtape
always returns the output we intend it to, like a sorted or filtered list of tracks. Careful access means fewer bugs.
A quick note:
If we resort to locking down our class members to private
visibility, we cannot access the private members inside an extension function/property defined outside the class.
Therefore, we need to move get(index: Int)
and size
inside the Mixtape
class block.
Small but incremental wins
As always, improvements like subscripts aren’t going to make our code faster or increase our productivity tenfold.
These are small improvements, like type aliases or inline classes which compound to a clean and well-maintained project.
Choose how you use them.