Kotlin Demystified: The power of `when`
I have a confession. I’m a bit of a gamer. The Legend of Zelda games are particular favorites. Imagine my excitement when I learned there were “randomizers”, programs that would shuffle items, dungeon entrances, map layouts, and character statistics in the game so even experienced players have fresh experiences. Randomizers for some of my absolute favorite titles, including the original
Often, emulators can be extremely complicated to implement. But it turns out that emulating a system like the NES on any modern computer isn’t particularly challenging. At its heart, an NES emulator is a very simple loop that looks at the next instruction and executes it.
Basics
We could write a huge if-else-if list with all of the instructions, but that will be tough to maintain. Instead, let’s use when
.
val instruction = fetchNextInstruction()when (instruction) { 0x0 -> handleBreak() 0x8 -> handlePushProcessorStatus() 0x10 -> handleBranchOnPlus() // Many other instructions... else -> throw IllegalStateException("Unknown instruction: ${instruction.toHex()}")}inline fun Int.toHex() = this.toString(16)
In this implementation, when
is nearly identical to the switch
statement from C++ or Java.
The reason it’s only nearly identical is because, as someone coming from either C++ or Java will notice, there aren’t any break
statements. Statements that are part of when
do not fall through.
And this is only scratching the surface.
As an expression
Game cartridges for the NES came in many different varieties even though they all mostly used the same connector. They had different types and amounts of ROM and RAM, including RAM that had a battery to allow saving the game. Some cartridges even included extra hardware on the cartridge itself. The circuitry on the cartridge that handled this is called a “mapper”.
Based on the realization that when
is used like switch
in Kotlin, code like this is one way decide which mapper to use for a particular game:
fun getCartridgeMapper(rom: Cartridge): Mapper { var mapper: Mapper? = null when (rom.mapperId) { 1 -> mapper = MMC1(rom) 3 -> mapper = CNROM(rom) 4 -> mapper = MMC3(rom) // Etc… } return mapper ?: throw NotImplementedError("Mapper for ${rom.mapperId} not yet implemented")}
This is okay, but it’s not ideal. The reference doc describes when
as an expression, meaning that it can have a value (just like if
in Kotlin). With that in mind, the function above can be simplified:
fun getCartridgeMapper(rom: Cartridge): Mapper = when (rom.mapperId) { 1 -> MMC1(rom) 3 -> CNROM(rom) 4 -> MMC3(rom) // Etc... else -> throw NotImplementedError("Mapper for ${rom.mapperId} not yet implemented")}
This gets rid of the temporary variable and converts the block body of that function to an expression body, which keeps the focus on the important part of the code.
Beyond simple case
statements
Another limitation of switch
is that values are limited to constant expressions. In contrast, when
allows for a variety of expressions to be used, such as range checks. For example, the emulator needs to treat the address of memory to read differently depending on what bit of emulated hardware is holding the data. The code to do this lives in our mapper.
val data = when (addressToRead) { in 0..0x1fff -> { // With this mapper, the graphics for the game can be in one of two locations. // We can figure out which one to look in based on the memory address. val bank = addressToRead / 0x1000 val bankAddress = addressToRead % 0x1000 readBank(bank, bankAddress) } in 0x6000..0x7fff -> { // There's 8k of program (PRG) RAM in the cartridge mapped here. readPrgRam(addressToRead - 0x6000) } // etc...}
You can also use the is
operator to check the type of the parameter to when
. This is especially nice when using a sealed class:
sealed class Interruptclass NonMaskableInterrupt : Interrupt()class ResetInterrupt : Interrupt()class BreakInterrupt : Interrupt()class InterruptRequestInterrupt(val number: Int) : Interrupt()
And then…
interrupt?.let { val handled = when (interrupt) { is NonMaskableInterrupt -> handleNMI() is ResetInterrupt -> handleReset() is BreakInterrupt -> handleBreak() is InterruptRequestInterrupt -> handleIRQ(interrupt.number) }}
Notice that there’s no need for an else
there. This is because Interrupt
is a sealed class, and the compiler knows all the possible types of interrupt
. If we miss a type, or a new interrupt type is added later, the compiler will fail with an error indicating that the when
must be exhaustive or an else
branch must be added.
when
can also be used without a parameter. In this case it acts like an if-then-else expression where each case is evaluated as a Boolean expression. Reading from top to bottom, the first case that evaluates to true
is executed.
when { address < 0x2000 -> writeBank(address, value) address >= 0x6000 && address < 0x8000 -> writeSRam(address, value) address >= 0x8000 -> writeRegister(address, value) else -> throw RuntimeException("Unhandled write to address ${address.toHex()}")}
One more thing
Previously, in order to use the return of a function in a when
expression, the only way to do it would be something like this:
getMapper().let { mapper -> when (mapper) { is MMC5 -> mapper.audioTick() is Mapper49 -> mapper.processQuirks() // Etc... }}
Starting with Kotlin 1.3M1, it’s possible to optimize this code by creating a variable in the parameter of the when
expression.
when (val mapper = getMapper()) { is MMC5 -> mapper.audioTick() is Mapper49 -> mapper.processQuirks() // Etc... }
Similar to the example with let
, mapper
is scoped to the when
expression itself.
Conclusion
We’ve seen that when
works like switch
does in C++ and Java, and that it can be used in many powerful ways, such as acting as an expression, including ranges for Comparable
types, and simplifying long if-then-else blocks.
If you’re interested in learning more about NES emulation (and Kotlin), checkout Felipe Lima’s KTNES project on GitHub.
Be sure to follow the Android Developers publication for more great content, and stay tuned for more articles on Kotlin!