Mastering Kotlin indexed access operator
Manh-Ha VU
Posted on May 24, 2020
Indexed access operator in Kotlin provides an easy-to-read syntax for random-access like data structures like Array, List and Map, ... But Kotlin is powerful enough that we can implement our own indexed access operator for our custom data structure. In this post, we will walk through a chessboard example to understand better this operator and also operator overloading in general.
You might have seen it, many times
If you have ever called the set/get method on a List/Array/Map on Kotlin,
nutritionFacts.set("Saturated fat", 10.grams)
// and later on
nutritionFacts.get("Saturated fat")
you might have encountered this suggestion from IntelliJ/Android Studio: "Should be replaced with indexing ...". And if you accept the suggestion, your code might turn out to be like this:
nutritionFacts["Saturated fat"] = 10.grams
// and retrieve
val quantity = nutritionFacts["Saturated fat"]
This situation can be seen often when you copy a code in Java then convert it to Kotlin in IntelliJ/Android Studio. The pair of brackets [ ] which encapsulate an index value (a string "Saturated fat" in the previous example) is the indexed access operator. It serves generally two purposes: get (retrieve) and set (mutate) value of an index. The set part is of course only applicable for mutable collections (Array, MutableList, HashMap, ...).
This operator is very obvious if you are using a collection type and key value of some primitive types (Int, String, ...). But what if we want the to use this operator on a data structure of our own?
In the next sections, we will work on a data structure which represents a chess board and we will implement this operator on this data structure to simplify access
We start with a chess board model
enum class Kind {
KING, QUEEN, ROOK, BISHOP, KNIGHTS, PAWN
}
enum class Color {
BLACK, WHITE
}
data class Piece(
val color: Color,
val kind: Kind
)
class ChessBoard {
private val squares: Array<Array<Piece?>> = Array(SIZE) {
Array(SIZE) { null }
}
companion object {
const val SIZE: Int = 8
}
}
We then need a way to set/assign a piece to a square in a chess board. It is necessary if we want to implement higher level operation (move, checkmate, ...). A simplest way could be:
class ChessBoard {
fun set(row: Int, column: Int, piece: Piece?) {
squares[row][column] = piece
}
}
val blackRook = Piece(BLACK, ROOK)
board.set(0, 7, blackRook) // assign blackRook to square (0, 7)
// later
board.set(0, 7, null) // remove the rook
Even if the set method is easy to understand for a developer, it is not obvious for a chess player. We should always aim to use the same language as domain experts. Otherwise, we also have leaked the implementation detail (array index based for row and column) to our API (set method). And the set method signature does not restrict the value for row and column.
Refactoring with indexed access operator
The standard method for recording moves in chess is algebraic notation, meaning using pairs like b4, e6, ... to identify squares in the board. To be coherent with the game we will you this notation in our code.
// Ranks (rows) denoted 1 to 8 from bottom to top according to White's perspective
//
// Raw value of rank is used as array index in chessboard. It is reversed compared to rank
// because rows in array is 0 to 7 from top to bottom
enum class Rank(internal val rawValue: Int) {
ONE(7), TWO(6), THREE(5), FOUR(4), FIVE(3), SIX(2), SEVEN(1), EIGHT(0)
}
// Columns
enum class File(internal val rawValue: Int) {
A(0), B(1), C(2), D(3), E(4), F(5), G(6), H(7)
}
Then we can update our set method:
class ChessBoard {
fun set(file: File, rank: Rank, piece: Piece?) {
squares[rank.rawValue][file.rawValue] = piece
}
}
val blackRook = Piece(BLACK, ROOK)
board.set(H, ONE, blackRook) // assign blackRook to square h1
Now we have a better method signature, if we can think about the indexing syntax:
board[H, ONE] = blackRook
by just adding operator keyword to our set method and voilà. Yes, that's easy as pie 😉.
class ChessBoard {
operator fun set(file: File, rank: Rank, piece: Piece?) {
squares[rank.rawValue][file.rawValue] = piece
}
}
Behind the scene, the operator keyword and the method name set is telling Kotlin compiler that we are defining the behavior of indexed access operator for our class Chessboard. The name set is mandatory for this operator.
And we can now define the accessor method (get) with the same idea:
class ChessBoard {
operator fun get(file: File, rank: Rank): Piece? {
return squares[rank.rawValue][file.rawValue]
}
}
val piece = board[H, ONE]
Alternative implementation
Depending on your taste, you might prefer 1D index to 2D one, like
val piece = board[sqr(H, ONE)]
We just need a little adaptation for previous code:
data class Square(
val file: File,
val rank: Rank
)
fun sqr(file: File, rank: Rank): Square {
return Square(file, rank)
}
class Chessboard {
operator fun set(square: Square, piece: Piece?) {
squares[square.rank.rawValue][square.file.rawValue] = piece
}
operator fun get(square: Square): Piece? {
return squares[square.rank.rawValue][square.file.rawValue]
}
}
Conclusion
Indexed access operator is a great improvement for container-like data structures with random access characteristic to increase the readability of your code. In this post, we've gone through implementations of 2D and 1D index for a custom data structure. You have now all it takes to implement your own.
Indexed access operator is one specific case of operator overloading. If done incorrectly or used abusively, instead of making our code easier to read, it can lead to very unnecessary cryptic code. As with great power comes great responsibility, we should use them with caution.
Posted on May 24, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.