1. Overview
In this tutorial, we’ll look at the Map collection type in Kotlin. We’ll start with the definition of a map and its characteristics.
Then we’ll look into how to make maps in Kotlin. The rest of the article will go over common operations like reading entries, changing entries, and data transformations.
2. Kotlin Maps
Maps are a common data structure in computer science. They’re also known as dictionaries or associative arrays in other programming languages. Maps can store a collection of zero or more key-value pairs.
Each key in a map is unique, and it can only be associated with one value. The same value can be associated with multiple keys though.
The Map interface is one of the primary collection types in Kotlin. We can declare the keys and values to be any type; there are no restrictions:
interface Map<K, out V>
In Kotlin, these key-value pairs are called entries and are represented by the Entry interface:
interface Entry<out K, out V>
Note that Map instances are immutable. We can’t add, remove, or change entries after it’s created. If we require a mutable map, Kotlin provides a MutableMap type, which allows us to modify entries after creation:
interface MutableMap<K, V> : Map<K, V>
Maps are a powerful tool for programmers because they support fast read and write access, even with large sets of data. This is because key lookups and insertions are normally implemented with hashing, which is an O(1) operation. Let’s look at how to use maps in Kotlin!
3. Constructing Maps
Kotlin includes several Map implementations in the standard library. The two primary types are LinkedHashMap and HashMap. The main difference between them is that a LinkedHashMap maintains insertion order when iterating over its entries. Like any class in Kotlin, you can instantiate them using a default constructor:
val iceCreamInventory = LinkedHashMap<String, Int>()
iceCreamInventory["Vanilla"] = 24
However, Kotlin provides more efficient ways of creating maps through the Collections API. Let’s look at these next.
3.1. Factory Functions
To declare and populate a Map instance in one operation, we use the mapOf and mutableMapOf functions. They accept a list of Pairs which can be passed as var args. We also make use of the “to” infix operator to create these Pairs on the fly:
val iceCreamInventory = mapOf("Vanilla" to 24, "Chocolate" to 14, "Rocky Road" to 7)
The mapOf function returns an immutable Map type, and mutableMapOf returns a MutableMap. We should use the latter only if we need to modify the entries after it’s created.
3.2. Initializing a Map With Entries Conditionally
The mapOf and mutableMapOf functions are handy for initializing a map with entries. However, sometimes, we would like to create a map and put the entries into the map conditionally. An example can explain it quickly. Let’s say we have four Pairs:
val chocolatePair = "Chocolate" to 3
val strawberryPair = "Strawberry" to 7
val vanillaPair = "Vanilla" to 5
val rockyRoadPair = "Rocky Road" to 10
Similarly, we want to create a map and put the pairs above into the map in one shot. However, we have an additional requirement this time:
- “Chocolate” and “Strawberry” entries must always be put into the map.
- For “Vanilla” and “Rocky Road“, we put them into the map only if their values are greater than 5.
Following this requirement, the “Vanilla” entry shouldn’t be added to the map, as its value is 5. Therefore, the expected map should look like this:
val expectedMap = mapOf(chocolatePair, strawberryPair, rockyRoadPair)
Simply using the mapOf function cannot solve this problem. But we can first check the candidate entries and build a List
val map1 = listOfNotNull(chocolatePair,
strawberryPair,
vanillaPair.takeIf { it.second > 5 },
rockyRoadPair.takeIf { it.second > 5 }).toMap()
assertEquals(expectedMap, map1)
It’s worth mentioning that if the predicate check in the takeIf function returns false, the expression, for example, vanillaPair.takeIf { it.second > 5 }, returns null*.* Further, the listOfNotNull function filters out all null values.
Alternatively, we can use the built-in map builder – the buildMap function:
val map2 = buildMap {
put(chocolatePair.first, chocolatePair.second)
put(strawberryPair.first, strawberryPair.second)
if (vanillaPair.second > 5) {
put(vanillaPair.first, vanillaPair.second)
}
if (rockyRoadPair.second > 5) {
put(rockyRoadPair.first, rockyRoadPair.second)
}
}
assertEquals(expectedMap, map2)
The buildMap function allows us to execute a set of builder actions to initialize the map, such as put. Therefore, we can use buildMap to initialize a map flexibly.
3.3. Using Kotlin Functional APIs
Kotlin’s standard library comes with many useful APIs that allow us to generate Maps in very succinct ways. For example, let’s say we have a list of IceCreamShipment objects. Each IceCreamShipment has a flavor and a quantity property:
val shipments = listOf(
IceCreamShipment("Chocolate", 3),
IceCreamShipment("Strawberry", 7),
IceCreamShipment("Vanilla", 5),
IceCreamShipment("Chocolate", 5),
IceCreamShipment("Vanilla", 1),
IceCreamShipment("Rocky Road", 10),
)
We want to generate a map of our inventory from this list. A common way to do this is to iterate over the list. Each shipment either creates or updates the map entry for its flavor:
val iceCreamInventory = mutableMapOf<String, Int>()
for (shipment in shipments){
val currentQuantity = iceCreamInventory[shipment.flavor] ?: 0
iceCreamInventory[shipment.flavor] = currentQuantity + shipment.quantity
}
This implementation would work, but a more idiomatic way to generate this map is by using Kotlin’s functional APIs:
val iceCreamInventory = shipments
.groupBy({ it.flavor }, { it.quantity })
.mapValues { it.value.sum() }
We use groupBy to associate the flavors with their quantities, and then we reduce the list of quantities into a single sum using the mapValues function. If we knew our list didn’t have multiple entries for each key (in this case, the ice cream flavor), we could’ve used the map or associateBy functions instead.
If we find we’re using a MutableMap just to initially populate it, there’s often a better way to generate it using Kotlin’s functional APIs instead**.** In general, Kotlin provides many useful methods to accomplish common goals like converting a collection to a map.
4. Accessing Map Entries
We use the get method to retrieve values from maps. Kotlin also allows the use of bracket notation as a shorthand for the get method:
val map = mapOf("Vanilla" to 24)
assertEquals(24, map.get("Vanilla"))
assertEquals(24, map["Vanilla"])
There are some getter methods that define a default action in case a key doesn’t exist in the map. The getValue method will throw an exception if the given key isn’t found:
assertThrows(NoSuchElementException::class.java) { map.getValue("Banana") }
The getOrElse method accepts a lambda function, which is executed when the key isn’t on the map. The final statement in the lambda is used as the return value as well:
assertEquals(0, map.getOrElse("Banana", { print("Warning: Flavor not found in map"); 0 }))
Finally, the getOrDefault method returns the provided default value if the key isn’t present:
assertEquals(0, map.getOrDefault("Banana", 0))
5. Adding and Updating Entries
If we’re using a MutableMap, then we can add new entries with the put method. We can use the bracket notation as a shorthand again:
val iceCreamSales = mutableMapOf<String, Int>()
iceCreamSales.put("Chocolate", 1)
iceCreamSales["Vanilla"] = 2
It’s also possible to add multiple entries with the putAll method, which accepts a collection of Pairs to add to the map. Alternatively, we can use the plus-assign operator (+=) to add all the entries from one map to another:
iceCreamSales.putAll(setOf("Strawberry" to 3, "Rocky Road" to 2))
iceCreamSales += mapOf("Maple Walnut" to 1, "Mint Chocolate" to 4)
Note that all of the methods above will override the current value if the key already exists in the map. If we want to update an entry instead, the best way is to use the merge method. For example:
val iceCreamSales = mutableMapOf("Chocolate" to 2)
iceCreamSales.merge("Chocolate", 1, Int::plus)
assertEquals(3, iceCreamSales["Chocolate"])
The merge method accepts a key, a value, and a remapping function. The remapping function defines how we want to merge the old value and the new value if the key already exists. In the case of our ice cream sales, we simply want to add them together.
6. Removing Entries
Mutable maps also provide methods for removing entries. The remove method accepts a key argument that we want to remove from the map. If the key doesn’t exist, calling remove won’t throw an exception. We could optionally use the minus-assign (-=) operator to perform the same operation:
val map = mutableMapOf("Chocolate" to 14, "Strawberry" to 9)
map.remove("Strawberry")
map -= "Chocolate"
assertNull(map["Strawberry"])
assertNull(map["Chocolate"])
The MutableMap interface also defines a clear method that deletes all the map’s entries at once.
7. Transforming Maps
Like other collection types in Kotlin, there are many ways of transforming maps for the needs of our application. Let’s look at a few useful operations. For all the examples shown below, this is the initial data in our inventory map:
val inventory = mutableMapOf(
"Vanilla" to 24,
"Chocolate" to 14,
"Strawberry" to 9,
)
7.1. Filtering
Kotlin provides several filter methods for maps. To filter by the enter key or value, there are filterKeys and filterValues, respectively. If we need to filter by both, there is the filter method. Here’s an example of filtering our ice cream inventory by the amount left:
val lotsLeft = inventory.filterValues { qty -> qty > 10 }
assertEquals(setOf("Vanilla", "Chocolate"), lotsLeft.keys)
The filterValues method applies the given predicate function to every entry’s value in the map. The filtered map is the collection of entries that match the predicate condition.
If we wanted to filter out entries that didn’t match this condition, we could’ve used the filterNot method instead.
7.2. Mapping
The map method accepts a transform function that converts every entry into something else. It returns a list of the mapped values:
val asStrings = inventory.map { (flavor, qty) -> "$qty tubs of $flavor" }
assertTrue(asStrings.containsAll(setOf("24 tubs of Vanilla", "14 tubs of Chocolate", "9 tubs of Strawberry")))
assertEquals(3, asStrings.size)
Here we used the map method to generate a list of strings describing our current inventory.
7.3. Using forEach
As a final example, we’ll use what we’ve learned already and introduce the forEach method. The forEach method performs an action on each entry in a given map. After a day of receiving shipments and selling ice cream, we need to update our store’s inventory map. We’ll subtract all the entries in the sales map and then add all the entries from the shipments map to update each flavor’s Quantity:
val sales = mapOf("Vanilla" to 7, "Chocolate" to 4, "Strawberry" to 5)
val shipments = mapOf("Chocolate" to 3, "Strawberry" to 7, "Rocky Road" to 5)
with(inventory) {
sales.forEach { merge(it.key, it.value, Int::minus) }
shipments.forEach { merge(it.key, it.value, Int::plus) }
}
assertEquals(17, inventory["Vanilla"]) // 24 - 7 + 0
assertEquals(13, inventory["Chocolate"]) // 14 - 4 + 3
assertEquals(11, inventory["Strawberry"]) // 9 - 5 + 7
assertEquals(5, inventory["Rocky Road"]) // 0 - 0 + 5
We’re making use of the with scope function here to keep our code tidy. This example shows how even complex operations can be performed easily with Kotlin’s powerful APIs.
8. Conclusion
In this article, we’ve introduced how to use maps in Kotlin. Maps are an essential tool for writing efficient code. There are many methods available; more than have been covered here. We should always reference the documentation to ensure we’re using the most appropriate methods in our code.
All the code used in this article, as well as some extra examples, is available over on GitHub.