1. Previous: Chapter 8
  2. Next: Chapter 10
Kotlin: An Illustrated Guide • Chapter 9

Collections: Maps

Chapter cover image

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.

A two-column table of tools - hardware in the left column, tools in the right column. Hammer Wrench Wrench Slotted Screwdriver Phillips Screwdriver Tool to Use Nail Hex Nut Hex Bolt Slotted Screw Phillips Screw Hardware

“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.”

How to scan a table. (Easy stuff!) Hammer Wrench Wrench Slotted Screwdriver Phillips Screwdriver Tool to Use Nail Hex Nut Hex Bolt Slotted Screw Phillips Screw Hardware Which tool should be used with a slotted screw? A Slotted Screwdriver!

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.

val association = Pair("Nail", "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.

UML diagram showing the Pair class and its two properties. Pair<A,B> + first: A + second: B

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.

println(association.first)  // Nail
println(association.second) // Hammer

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.

val association = "Nail".to("Hammer")

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:

val association = "Nail" to "Hammer"

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

How to construct the type of a Pair variable. Pair<String, String> Type of the first thing Type of the second thing

And of course, we can specify the type explicitly like this:

val association: Pair<String, String> = "Nail" to "Hammer"

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.

The table again. Hammer Wrench Wrench Slotted Screwdriver Phillips Screwdriver Tool to Use Nail Hex Nut Hex Bolt Slotted Screw Phillips Screw Hardware

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.
Keys and values in a table. Hammer Wrench Wrench Slotted Screwdriver Phillips Screwdriver Tool to Use Nail Hex Nut Hex Bolt Slotted Screw Phillips Screw Hardware Keys Values Entries in a table. Hammer Wrench Wrench Slotted Screwdriver Phillips Screwdriver Tool to Use Nail Hex Nut Hex Bolt Slotted Screw Phillips Screw Hardware Entries

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.

The tool table, showing that no duplicates are allowed in the left-hand column, but they're allowed in the right-hand column. Hammer Wrench Wrench Slotted Screwdriver Phillips Screwdriver Tool to Use Nail Hex Nut Hex Bolt Slotted Screw Phillips Screw Hardware No duplicates in this column Duplicates allowed in this column Duplicated value!

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.

val toolbox = mapOf(
    "Nail" to "Hammer",
    "Hex Nut" to "Wrench",
    "Hex Bolt" to "Wrench",
    "Slotted Screw" to "Slotted Screwdriver",
    "Phillips Screw" to "Phillips Screwdriver",
)

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:

Similarities between the Kotlin code to create a map, and a hand-drawn table. Hammer Wrench Wrench Slotted Screwdriver Phillips Screwdriver Tool to Use Nail Hex Nut Hex Bolt Slotted Screw Phillips Screw Hardware val toolbox = mapOf ( "Nail" to "Hammer" , "Hex Nut" to "Wrench" , "Hex Bolt" to "Wrench" , "Slotted Screw" to "Slotted Screwdriver" , "Phillips Screw" to "Phillips Screwdriver" , )

Just as with lists, sets, and other variables, you can use println() to print out the contents of a map.

println(toolbox)

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.

{Nail=Hammer, Hex Nut=Wrench, Hex Bolt=Wrench, Slotted Screw=Slotted Screwdriver, Phillips Screw=Phillips Screwdriver}

Just like with Pair, the type of a Map variable depends on the type of the key and the type of the value.

How to construct the type of a Map variable. Map<String, String> Type of the key Type of the value

So, we could write out the type explicitly like this:

val toolbox: Map<String, String> = mapOf(
    "Nail" to "Hammer",
    "Hex Nut" to "Wrench",
    "Hex Bolt" to "Wrench",
    "Slotted Screw" to "Slotted Screwdriver",
    "Phillips Screw" to "Phillips Screwdriver",
)

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.

val tool = toolbox.get("Nail")
println(tool) // Hammer

Similar to lists, you can also use the indexed access operator with maps to get a value.

val tool = toolbox["Nail"]
println(tool) // Hammer

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.

val tool = toolbox.getValue("Nail")
println(tool) // Hammer

val anotherTool = toolbox.getValue("Wing Nut") // Error at runtime

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!

val tool = toolbox.getOrDefault("Hanger Bolt", "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.

val toolbox = mutableMapOf(
    "Nail" to "Hammer",
    "Hex Nut" to "Wrench",
    "Hex Bolt" to "Wrench",
    "Slotted Screw" to "Slotted Screwdriver",
    "Phillips Screw" to "Phillips Screwdriver",
)

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.

toolbox.put("Lumber", "Saw")

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.

toolbox["Lumber"] = "Saw"

You can also change an existing value exactly the same way. Just provide a key that already exists.

toolbox["Hex Bolt"] = "Nut Driver"

Finally, you can remove an entry using the remove() function.

toolbox.remove("Lumber")

Note that although you can change a value, you cannot change a key. Instead, you can remove a key and insert a new entry.

toolbox.remove("Phillips Screw")
toolbox["Cross Recess Screw"] = "Phillips Screwdriver"

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!

var toolbox = mapOf(
    "Nail" to "Hammer",
    "Hex Nut" to "Wrench",
    "Hex Bolt" to "Wrench",
    "Slotted Screw" to "Slotted Screwdriver",
    "Phillips Screw" to "Phillips Screwdriver",
)

// Add an entry
toolbox = toolbox + Pair("Lumber", "Saw")

// Update an entry
toolbox = toolbox + Pair("Hex Bolt", "Nut Driver")

// Remove an entry
toolbox = toolbox - "Lumber"

// Simulate changing a key
toolbox = toolbox - "Phillips Screw"
toolbox = toolbox + Pair("Cross Recess Screw", "Phillips Screwdriver")

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.

UML class diagram for the Map.Entry class. Map.Entry<K,V> + key: K + value: V

Here’s how you can use the forEach() function on a Map.

toolbox.forEach { entry -> 
    println("Use a ${entry.value} on a ${entry.key}") 
}

When you run this code, here’s what you’ll see.

Use a Hammer on a Nail
Use a Wrench on a Hex Nut
Use a Wrench on a Hex Bolt
Use a Slotted Screwdriver on a Slotted Screw
Use a Phillips Screwdriver on a Phillips Screw

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:

Breakdown of the forEach() function. Lambda parameter Code to run for each entry toolbox. forEach { entry -> println ( " ${ entry . key } and ${ entry . value } " ) } Map variable

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.

val screwdrivers = toolbox.filter { entry -> 
    entry.value.contains("Screwdriver")
}

The result is a new Map that contains only the screwdrivers.

The effect of using `filter()` on a map. Slotted Screwdriver Phillips Screwdriver Tool to Use Slotted Screw Phillips Screw Hardware Hammer Wrench Wrench Slotted Screwdriver Phillips Screwdriver Tool to Use Nail Hex Nut Hex Bolt Slotted Screw Phillips Screw Hardware

In this case, we filtered on the value, but you can just as easily filter by the keys.

val screwdrivers = toolbox.filter { entry ->
    entry.key.contains("Screw")
}

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.

val newToolbox = toolbox
    .mapKeys { entry -> entry.key.replace("Hex", "Flange") }
    .mapValues { entry -> entry.value.replace("Wrench", "Ratchet") }
The effect of using the `mapKeys()` and `mapValues()` functions. Hammer Ratchet Ratchet Slotted Screwdriver Phillips Screwdriver Tool to Use Nail Flange Nut Flange Bolt Slotted Screw Phillips Screw Hardware Hammer Wrench Wrench Slotted Screwdriver Phillips Screwdriver Tool to Use Nail Hex Nut Hex Bolt Slotted Screw Phillips Screw Hardware

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…

val tool = toolbox.getOrDefault("Hanger Bolt", "Hand")
val anotherTool = toolbox.getOrDefault("Dowel Screw", "Hand")
val oneMoreTool = toolbox.getOrDefault("Eye Bolt", "Hand")

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:

toolbox = toolbox.withDefault { key -> "Hand" }

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!

val tool = toolbox.getValue("Hanger Bolt")
val anotherTool = toolbox.getValue("Dowel Screw")
val oneMoreTool = toolbox.getValue("Eye Bolt")

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.

class Tool(
    val name: String, 
    val weightInOunces: Int,
    val correspondingHardware: String,
)

Now, let’s create a list of Tool objects, so that they include the tools from Mr. Andrews’ toolbox.

val tools = listOf(
    Tool("Hammer", 14, "Nail"),
    Tool("Wrench", 8, "Hex Nut"),
    Tool("Wrench", 8, "Hex Bolt"),
    Tool("Slotted Screwdriver", 5, "Slotted Screw"),
    Tool("Phillips Screwdriver", 5, "Phillips Screw"),
)

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:

val toolbox = tools.associate { tool ->
    tool.correspondingHardware to tool.name
}

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.

The effect of calling the associate() function. Map<String,String> Hammer Nail Wrench Hex Nut Wrench Hex Bolt Slotted Screwdriver Slotted Screw Phillips Screwdriver Phillips Screw Key Value Phillips Screwdriver 5 Phillips Screw correspondingHardware weightInOunces name Slotted Screwdriver 5 Slotted Screw correspondingHardware weightInOunces name Wrench 8 Hex Bolt correspondingHardware weightInOunces name Wrench 8 Hex Nut correspondingHardware weightInOunces name Hammer 14 Nail correspondingHardware weightInOunces name List<Tool>

Here’s a breakdown of the associate() function.

Describes the different parts of the code when using the associate() function. Lambda parameter Pair to insert as an entry in the new map. val toolbox = tools . associate { tool -> tool . correspondingHardware to tool . name } Original list variable New map variable

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.

val toolbox = tools.associate { tool ->
    tool.name to tool.correspondingHardware
}

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.

The effect of calling the associate() function when there would have been duplicate keys. Map<String,String> Hammer Wrench Slotted Screwdriver Phillips Screwdriver Nail Hex Bolt Slotted Screw Phillips Screw Key Value Phillips Screwdriver 5 Phillips Screw correspondingHardware weightInOunces name Slotted Screwdriver 5 Slotted Screw correspondingHardware weightInOunces name Wrench 8 Hex Bolt correspondingHardware weightInOunces name Wrench 8 Hex Nut correspondingHardware weightInOunces name Hammer 14 Nail correspondingHardware weightInOunces name List<Tool>

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.

val toolsByName = tools.associateBy { tool -> tool.name }
The effect of calling the associateBy() function. Key Value Map<String,Tool> Hammer Wrench Slotted Screwdriver Phillips Screwdriver Phillips Screwdriver 5 Phillips Screw correspondingHardware weightInOunces name Slotted Screwdriver 5 Slotted Screw correspondingHardware weightInOunces name Wrench 8 Hex Bolt correspondingHardware weightInOunces name Hammer 14 Nail correspondingHardware weightInOunces name Phillips Screwdriver 5 Phillips Screw correspondingHardware weightInOunces name Slotted Screwdriver 5 Slotted Screw correspondingHardware weightInOunces name Wrench 8 Hex Bolt correspondingHardware weightInOunces name Wrench 8 Hex Nut correspondingHardware weightInOunces name Hammer 14 Nail correspondingHardware weightInOunces name List<Tool>

With this map, you can easily get a tool by its name!

val hammer = toolsByName["Hammer"]

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.

val toolWeightInPounds = tools.associateWith { tool ->
    tool.weightInOunces * 0.0625
}
The effect of calling the associateWith() function. Key Value Map<Tool,Double> 0.3125 0.3125 0.5 0.5 0.875 Phillips Screwdriver 5 Phillips Screw correspondingHardware weightInOunces name Slotted Screwdriver 5 Slotted Screw correspondingHardware weightInOunces name Wrench 8 Hex Bolt correspondingHardware weightInOunces name Wrench 8 Hex Nut correspondingHardware weightInOunces name Hammer 14 Nail correspondingHardware weightInOunces name Phillips Screwdriver 5 Phillips Screw correspondingHardware weightInOunces name Slotted Screwdriver 5 Slotted Screw correspondingHardware weightInOunces name Wrench 8 Hex Bolt correspondingHardware weightInOunces name Wrench 8 Hex Nut correspondingHardware weightInOunces name Hammer 14 Nail correspondingHardware weightInOunces name List<Tool>

To get the weight of a hammer, you’d need to have a hammer object already.

val hammerWeightInPounds = toolWeightInPounds[hammer]

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.

Tools grouped by weight in ounces. 14 oz each 8 oz each 5 oz each

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.

val toolsByWeight = tools.groupBy { tool ->
    tool.weightInOunces
}

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.

The effect of the calling the groupBy() function. Key Value Map<Int,List<Tool 5 8 14 Phillips Screwdriver 5 Phillips Screw correspondingHardware weightInOunces name Slotted Screwdriver 5 Slotted Screw correspondingHardware weightInOunces name List<Tool> Wrench 8 Hex Bolt correspondingHardware weightInOunces name Wrench 8 Hex Nut correspondingHardware weightInOunces name List<Tool> Hammer 14 Nail correspondingHardware weightInOunces name List<Tool> Phillips Screwdriver 5 Phillips Screw correspondingHardware weightInOunces name Slotted Screwdriver 5 Slotted Screw correspondingHardware weightInOunces name Wrench 8 Hex Bolt correspondingHardware weightInOunces name Wrench 8 Hex Nut correspondingHardware weightInOunces name Hammer 14 Nail correspondingHardware weightInOunces name List<Tool>

Here’s a breakdown of the groupBy() function:

Breakdown of the groupBy() function. Lambda parameter Elements that return the same thing here will be grouped together val toolsByWeight = tools . groupBy { tool -> tool . weightInOunces } Original list variable New map variable

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:

val toolNamesByWeight = tools.groupBy(
    { tool -> tool.weightInOunces }, 
    { tool -> tool.name }
)
The effect of the calling the groupBy() function with two arguments. Key Value Map<Int,List<String 5 8 14 List<String> Phillips Screwdriver Slotted Screwdriver List<String> Wrench Wrench List<String> Hammer Phillips Screwdriver 5 Phillips Screw correspondingHardware weightInOunces name Slotted Screwdriver 5 Slotted Screw correspondingHardware weightInOunces name Wrench 8 Hex Bolt correspondingHardware weightInOunces name Wrench 8 Hex Nut correspondingHardware weightInOunces name Hammer 14 Nail correspondingHardware weightInOunces name List<Tool>

Summary

Enjoying this book?
Pick up the Leanpub edition today!

Kotlin: An Illustrated Guide is now available on Leanpub See the book on Leanpub

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:

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.

Share this article:

  • Share on Twitter
  • Share on Facebook
  • Share on Reddit