Introduction
In the last chapter, we saw how collections, such as lists and sets, can be used to do things that couldn’t be done easily with separate, individual variables. In this chapter, we’re going to look at one more kind of collection - a map.
The Right Tool for the Job
“You gotta use the right tool for the job.” That’s what Mr. Andrews taught his young son Jim, who was just starting to learn how to become a handyman like his old man. “When you’ve got a nail, you need to use a hammer, not a screwdriver.”
In order to help Jim pick out the right tool, he sketched out a table of the different hardware and tools in his toolbox.
“Now, with this table, you can easily look up what tool you need. Just scan down the left-hand column for the hardware you need to work with, and then scan across to see the right tool to use.”
Tables like this show that there are associations between things - a nail is associated with a hammer, a hex nut is associated with a wrench, and so on. In this chapter, we’ll build out Kotlin’s equivalent of a table like this, but before we do, let’s start by creating a single association.
Associating Data
One simple way to associate two values is to use a class called Pair
. The constructor of this class has two parameters. You can call its constructor with any two objects, regardless of their types. In our case, let’s associate two String
objects - one for a nail, and one for a hammer.
Pair
is a very simple class that has two properties, first
and second
, which you can use to get the values back out of it. Below is a UML diagram showing the Pair
class. The types of first
and second
depend on the types of the arguments you give it when calling its constructor, so in this diagram, we will just use A
and B
as placeholders for the actual types.
The first
property will be whatever the first argument was when you called the constructor, and the second
will be whatever the second argument was.
Easy, right?
Now, instead of calling the constructor of the Pair
class, it’s sometimes more natural to use a function called to()
, which will call the Pair
constructor for you. This function can be called on any object at all. Let’s update our code so that it uses the to()
function.
When reading this code, you might say, “When I have a Nail, then I should go to
a Hammer.” Both Listing 9.1 and Listing 9.3 do the same thing - they create a Pair
where the left-hand value is assigned to the property called first
and the right-hand value is assigned to the property called second
.
The to()
function also has a special characteristic about it that lets you use it without the punctuation! So, you can also create this same Pair
like this:
Notice that this is the same as Listing 9.3 above, except that the .
, the (
, and the )
are all missing. When a function lets you call it this way, it’s called an infix function. We won’t see infix functions often, but it’s important to know that they exist so that they won’t confuse you when you see code like this.
So far, we’ve used type inference so that we don’t have to write out the type of the association
variable. As with List
and Set
in the previous chapter, the type of a Pair
variable depends on the type of the things that it contains. Since both “Nail” and “Hammer” have type String
, the type of the association
variable is Pair<String, String>
.
And of course, we can specify the type explicitly like this:
Now that we’ve successfully made a single association, we can do the same thing for the rest of the tools… and then put them all together into a map!
Map Fundamentals
Let’s look at Mr. Andrews’ table again.
In Kotlin, a table like this is called a map. You might be familiar with maps like street maps and treasure maps, but that’s not what we’re talking about here. The term comes from the world of mathematics, where a map defines a correspondence between elements of sets. Similarly, Kotlin maps define an association between each item in the left-hand column, and the corresponding item in the right-hand column.
Before we create our first map, let’s cover a few important terms:
- The items in the left-hand column of the table are called keys.
- The items in the right-hand column are called values.
- The association of a key and a value within a map is called a map entry.
There’s an important rule to keep in mind - each key in a map is unique. However, the values can be duplicated. In other words, you cannot have duplicate items in the left-hand column, but it’s fine in the right-hand column. You can see this in the table below, where the left-hand column items are unique, but the wrench appears twice in the right-hand column.
Creating a Map with mapOf()
Now that we understand the main concepts, it’s time to create our first map! To do this, we’ll use the mapOf()
function, passing in a Pair
for each association that we want in the map.
If you read the last chapter, you’ll notice that this looks similar to listOf()
and setOf()
, except that all of the elements here have two pieces - the key and the value, which are joined together in a Pair
.
You can see the similarites between the Kotlin map and Mr. Andrews’ table when you place them side by side:
Just as with lists, sets, and other variables, you can use println()
to print out the contents of a map.
When you print this out, the entries of the map will appear between braces. The keys are to the left of the equal signs, and the values are to the right.
Just like with Pair
, the type of a Map
variable depends on the type of the key and the type of the value.
So, we could write out the type explicitly like this:
Looking Up a Value
The most common thing that you’ll need to do with a map is to look up a value. When Jim has a nail, for example, he needs to look up which tool to use. Just as Jim would find the nail in the left-hand column and find the corresponding tool next to it, Kotlin can give you the value when you provide it a key. You can use the get()
function to do this.
Similar to lists, you can also use the indexed access operator with maps to get a value.
If you call get()
(or use the indexed access operator) with a key that does not exist in the map, it will return a null. This means the get()
function returns a nullable type rather than a non-nullable type! In Listing 9.9 and 9.10, it returns a String?
rather than a String
.
You can use the null-safety tools you learned about in Chapter 6 (such as the elvis operator) to get it back to a non-nullable type. Alternatively, you can call getValue()
instead of get()
. getValue()
will return a non-nullable type, but be warned - if you give it a key that does not exist, you’ll see an error message and your code will stop running.
You can also use getOrDefault()
to provide a default value if the key doesn’t exist. If Mr. Andrews doesn’t have a tool for a particular piece of hardware, he’ll just need to tighten it by hand!
Modifying a Map
As with the other collection types, maps come in two flavors of mutability - MutableMap
and an immutable Map
. The mutable variety allows you to change its contents, whereas an immutable map requires you to create a new map instance that you can assign to a new or existing variable.
Let’s look at how to change a MutableMap
first. To start with, we’ll need to use mutableMapOf()
to create the map, instead of just mapOf()
, which we used back in Listing 9.6.
To add a new entry, you can use the put()
function, where the first argument is the key, and the second argument is the value.
Just like with the get()
function, though, Kotlin developers typically use the indexed access operator instead of calling the put()
function directly. The following code accomplishes the same thing as Listing 9.14.
You can also change an existing value exactly the same way. Just provide a key that already exists.
Finally, you can remove an entry using the remove()
function.
Note that although you can change a value, you cannot change a key. Instead, you can remove a key and insert a new entry.
Immutable Maps
As with immutable lists and sets, you can use the plus and minus operators on an immutable map. Remember, doing so will create new map instances, which you’d typically assign to a variable. The following code demonstrates the same operations as we did above, but on an immutable map. Notice that we use the var
keyword here so that we can assign each result back to the same toolbox
variable!
Map Operations
As with List
and Set
, Map
objects have operations that can be performed on them, and some of them will look very familiar. Let’s start with the forEach()
function.
forEach()
The forEach()
function is almost identical to the one found on List
and Set
objects. It takes a lambda that you can use to do something with each entry in the map. Since maps store entries, the parameter of the lambda will be of type Map.Entry
.
Map.Entry
is very similar to the Pair
class that we looked at earlier in this chapter - it has two properties on it, but instead of being named first
and second
, they’re named key
and value
.
Here’s how you can use the forEach()
function on a Map
.
When you run this code, here’s what you’ll see.
Because it’s so similar to the forEach()
that you saw in the last chapter, you should be able to identify the main parts. Here they are:
Filtering
Similar to lists and sets, you can filter
a map. Keep in mind that, just as we saw with lists, this function doesn’t modify an existing map - it creates a new map instance, so you’ll probably want to assign the result to a variable.
Let’s filter down the toolbox to just screwdrivers.
The result is a new Map
that contains only the screwdrivers.
In this case, we filtered on the value
, but you can just as easily filter by the keys.
Mapping
Yes, you can map a Map
! Simply use the mapKeys()
and mapValues()
functions to convert its keys or values. Just like with the collection operations we looked at in the last chapter, you can create an operation chain. Let’s map both keys and values in one chain.
Map
objects have many other operations on them, which you can explore in Kotlin’s API docs. However, there’s one more operation that we’ll examine before continuing - withDefault()
.
Setting Default Values
As we saw earlier, you can use getOrDefault()
to gracefully handle cases where the key does not exist. However, this can quickly get out of control if you use the same default every time…
Instead, you can use an operation called withDefault()
, which will return a new map based on the original. In this new map, whenever you call getValue()
with a key that doesn’t exist, it will invoke a lambda and return the result. Here’s how it looks:
Now, instead of providing the default every time you try to get a value (as done in Listing 9.24 above), you can just call getValue()
normally. This is great, because if you ever wanted to change the default, you could make the change in one spot instead of many!
Keep in mind that this works with getValue()
but not with get()
or the indexed access operator, which will continue to return null
if the key is not found!
Now you know how to create and change maps, get values out of them, and use collection operations on them. But things get really fun when you start using maps in conjunction with other collections! Let’s look at that next.
Creating a Map from a List
We’ve created maps by hand using the mapOf()
function. It’s also possible to create maps that are based on existing list or set collections. With just a few important functions, you can slice and dice your data in many different ways! In order to do this, of course, we will need a list to start with.
Instead of using a simple String
to represent the tools in Mr. Andrews’ toolbox, let’s create a class, so that it can hold the name of the tool, its weight in ounces, and the corresponding hardware that it works with.
Now, let’s create a list of Tool
objects, so that they include the tools from Mr. Andrews’ toolbox.
Now that we have a list, we’re ready to create some maps from it!
Associating Properties from a List of Objects
You can use the associate()
function to create a map from a list of objects. To start with, let’s use associate()
to create a map similar to the one in Listing 9.6:
Hopefully you’re starting to feel more comfortable with collection operations at this point. For each element in the list, the associate()
function will invoke the lambda given to it. The lambda returns a key-value Pair
, which contains the key and value that you want in the resulting map.
Here’s a breakdown of the associate()
function.
Often, the number of elements in the resulting map will be the same as the number of elements in the original list. In some cases, it could have fewer. Because the keys in a map are all unique, if you try to add a key that already exists, it will overwrite the existing value. For example, let’s reverse the key and value in the lambda in Listing 9.29, so that the tool name is the key, and the hardware is the value.
The original list has two Tool
objects with a name
of Wrench
, so when associate()
encounters the first one, it’s added to the map, but when it encounters the second, it replaces the first value. So, the resulting map only includes Hex Bolt
rather than Hex Nut
, because of the two, Hex Bolt
came last.
So in this case, there are fewer entries in the map than elements in the original list. That is, there are only 4 entries in the map, compared with 5 elements in the list.
Other Association Functions
There are a few other variations of the associate()
function that are good to know. These are especially helpful if you want the original list element to be either the key or the value in the resulting map.
For example, if you want to create a map where the keys are the tool names and the value is the Tool
object, you can use associateBy()
. The lambda of this function returns just the key. The original list element itself will be the value.
With this map, you can easily get a tool by its name!
Inversely, if you want to create a map where the keys are the Tool
object and the value is specified in the lambda, you can use the associateWith()
function. The lambda of this function returns the value, and the original list element will be the key.
To get the weight of a hammer, you’d need to have a hammer object already.
Grouping List Elements into a Map of Lists
Sometimes when you’ve got a list, you want to split it up into multiple smaller lists, based on some characteristic. For example, we can take the tools
list and split it up by weight.
To do this, we can use the groupBy()
function. This function will run the provided lambda for each element in the list. Elements for which the lambda returns the same result will be assembled into a list, and inserted into a map.
The result is a Map
with one list of tools that weigh 14 ounces, another list of tools that weigh 8 ounces, and a third list of tools that weigh 5 ounces. The map’s key is the weight in ounces, and the map’s value is a list of tools that have that weight.
Here’s a breakdown of the groupBy()
function:
In case you want something other than the original list element in the resulting lists, you can also call this function with a second argument. For that one, give it a lambda that returns whatever you want in the resulting list. For example, if you only want the names of the tools in those lists, you can do this:
Summary
Well, Jim is well on his way to becoming a great handyman like his father. And with the knowledge you’ve gained over the past nine chapters, you’re well on your way to becoming a great Kotlin developer! Here’s what you learned in this chapter:
- How to associate two values with a
Pair
. - How to create a simple map with
mapOf()
andmutableMapOf()
. - How to look up a value in a map by its key.
- How to modify a map.
- How to perform operations on a map.
- How to associate keys and values from an existing list of objects.
- How to group elements from an existing list of objects.
Now that you’ve learned about collections - like lists, sets, and maps - you’ve opened up many possibilities in your code. In the next chapter, we’ll look at Receivers and Extensions.