Uniform list access with subscripts

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

Subscripts access strategy

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 Lists 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 Lists 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 Mixtapes 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.

Here's a sketch note on the topic

Subscripts sketch note