Recently, I had the opportunity to participate as a mentor in an introductory Golang bootcamp. It was a very fulfilling experience to introduce engineers to the fantastic world of Go.
One of the subjects I had the opportunity to teach was about maps.
Introduction to maps
Maps are one of the most useful and versatile data structures. They are present in most of programming languages, you might already know them as Dictionaries in Python, Hash-maps in Java, Objects in JavaScript or associative arrays in PHP, to mention some.
They provide fast lookups (Maps provide constant-time (O(1)) average-case complexity for insertion, retrieval, and deletion operations), automatic key uniqueness, and dynamic size among other features.
We can think about maps as containers where we can easily store information and retrieve it.
Declaring maps in Go
A map declaration looks like:
map[KeyType]ValueType
Where KeyType
is a type that can be compared, such as boolean, numeric, string, pointer, channel, interface, struct, or arrays containing only other comparable types.
ValueType
can be any kind of value.
var products map[string]float64
fmt.Printf("products is of type %T and its value is %#v\n", products, products)
fmt.Println(products == nil)
After declaring a map, its value will be nil
. A nil map behaves as an empty map for reads, but if you try to write on it, a runtime panic will be triggered.
fmt.Println(products["non existent key"]) // Prints an empty value as the key doesn't exist
// Attempting to write to a nil map will result in a runtime panic: assignment to entry in nil map
products["FashionCap"] = 99.99
Initializing maps
Same as with other types of values, there are a couple of different ways to initialize maps. You can use the make
function, which is a built-in function that allocates and initializes objects like slices, maps, or channels. Alternatively, you can use the short variable declaration. The choice between these methods depends on your specific requirements and team coding standards.
After initializing a map, we can start adding values to it.
var products = make(map[string]float64)
products["Cool Hat"] = 99.99
fmt.Printf("products is of type %T and its value is %#v\n", products, products)
Working with map elements
The syntax needed to work with maps is simple and familiar.
As mentioned, we can initialize a map with the short variable declaration, so we can use it to initialize a populated map:
account := map[string]string{
"dev": "691484518277",
"qa": "691515518215",
"stg": "632515518875",
}
We add values to a map using the map name, the key, and the data to add.
// mapName[key] = valueToStore
account["prod"] = "369524578943"
fmt.Printf("%#v\n", account)
We can obtain values from a map by specifying the key for the data we want.
devAccount := account["dev"]
fmt.Printf("%q\n", devAccount)
If the map doesn't contain a value associated with a given key, the map will return the "zero value" for the value type.
uatAccount := account["uat"]
fmt.Printf("%q\n", uatAccount)
Map operations
Go provide us with some tools to work with maps. For example, we can count the keys a map has:
account := map[string]string{
"dev": "691484518277",
"qa": "691515518215",
"stg": "632515518875",
"test": "",
}
fmt.Println("number of keys:", len(account))
Delete a map entry by key:
key := "dev"
delete(account, key)
When getting a value from a map using its key, the operation returns two values. The second value is a boolean that indicates whether the key exists in the map or not. You can use this boolean value to check if a key is present in the map before attempting to access its value.
if _, ok := account[key]; !ok {
fmt.Printf("key '%s' not found\n", key)
return
}
Go introduced a new maps package in its 1.21 version that introduces new utilities such as copy, clone, maps comparison functions and more!
Iterating over maps
We can iterate over a map by using a for
loop with range
. range
allow us to loop through a map obtaining each key and value present in the map:
a := numberToStr{
0: "zero",
1: "one",
2: "two",
3: "three",
4: "four",
5: "five",
6: "six",
7: "seven",
8: "eight",
9: "nine",
10: "ten",
}
for key, value := range a {
fmt.Println(key, value)
}
If you tried to run the previous sample code, you will find that the map elements are print out of order. The order is random by design. This encourages developers to not rely on key order and prevent developers to make wrong assumptions about the order.
If you wish to loop through a map in order, then you'll need to do it by yourself. That is, extracting the keys into a slice or array, sort it, and then use the sorted iterable to retrieve the map values by key:
a := numberToStr{
0: "zero",
1: "one",
2: "two",
// ...
}
// Extract the keys into a slice.
keys := make([]int, 0, len(a))
for key := range a {
keys = append(keys, key)
}
// Sort the keys.
sort.Ints(keys)
// Iterate through the sorted keys and access the map elements.
for _, key := range keys {
fmt.Println(key, a[key])
}
Maps and reference types
Maps are a reference type. This means that a map variable doesn't hold any value, but rather, it points to where the data is present in memory. When you assign a reference type to another variable or is passed as function argument, you are copying the reference, not the data.
You can think about this somewhat similar to real life tags. You might have a box with toys (the actual map data) labeled with a tag "Toys". You can slap a new tag labeled "Playing items" to the same box. The data is the same (the toy box), but now we have two variables ("Toys"/"Playing items") pointing to the same data.
// Maps are like tags on a box of toys.
toys := map[string]string{
"1": "car",
"2": "doll",
}
// Copy the tag to another box.
playingItems := toys
// Change the contents of the new box.
playingItems["1"] = "ball"
// The original box is also updated.
fmt.Println(toys["1"]) // "ball"
Key and values of different types
As previously mentioned, the keys of a map are of a comparable value and the keys can be of any value.
So, we could think about a case where we could have structs as map keys, this can be useful when associating addresses with specific individuals.
type person struct {
firstName string
lastName string
}
type address struct {
streetName string
number string
neighborhood string
zipCode string
}
peopleAddresses := map[person][]address{
person{
firstName: "Makko",
lastName: "Vela",
}: []address{
{
streetName: "Evergreen Terrace",
number: "742",
neighborhood: "Henderson",
zipCode: "90210",
},
{
streetName: "Spalding Way",
number: "420",
neighborhood: "Adamsbert",
zipCode: "63637",
},
},
}
makko := person{
firstName: "Makko",
lastName: "Vela",
}
fmt.Println(peopleAddresses[makko])
We can also have cases where we need to use nested maps, for example when representing a hierarchical structure:
familyTree := map[string]map[string]string{
"Juan": {
"father": "Miguel",
"mother": "Ana",
},
"María": {
"father": "Alfredo",
"mother": "Guadalupe",
},
}
// Accessing nested map values
fmt.Println("Juan's dad is:", familyTree["Juan"]["father"])
fmt.Println("María's mom is:", familyTree["María"]["mother"])
Map use cases
Among the most common use cases we have:
Data association
For example, looking up definitions for words in a dictionary.
dictionary := map[string]string{
"map": "a diagram or collection of data showing the spatial arrangement or distribution of something over an area",
}
fmt.Println(dictionary["map"])
Count items
Counting word occurrences in a text is useful, such as in text analysis or generating word clouds:
song := "Baby Shark, doo-doo, doo-doo, doo-doo, Baby Shark, doo-doo, doo-doo, doo-doo, Baby Shark, doo-doo, doo-doo, doo-doo, Baby Shark"
song = strings.ReplaceAll(song, ",", "")
words := strings.Split(song, " ")
lyricMap := map[string]int{}
for _, word := range words {
if _, found := lyricMap[word]; !found {
lyricMap[word] = 1
continue
}
lyricMap[word]++
}
fmt.Printf("%#v\n", lyricMap)
// output would be: map[string]int{"Baby":4, "Shark":4, "doo-doo":9}
Cache
Maps can also serve as a basic in-memory cache to store and retrieve previously computed values efficiently:
func cache(key string) int {
data, ok := cacheMap[key]
if !ok {
// do some expensive operation here to get `data`
data = 123
cacheMap[key] = data
fmt.Println("expensive operation")
return data
}
fmt.Println("serve from cache")
return data
}
func main(){
// prints "expensive operation"
cache("key")
// prints "serve from cache"
cache("key")
}
Switch
We can take advantage of maps allowing any value types, we can even use functions as map values for implementing dynamic behavior.
calculator := map[string]func(a, b float64) float64{
"sum": func(a, b float64) float64 {
return a + b
},
"subtraction": func(a, b float64) float64 {
return a - b
},
"multiplication": func(a, b float64) float64 {
return a * b
},
"division": func(a, b float64) float64 {
return a / b
},
}
fmt.Println(calculator["sum"](3, 4))
fmt.Println(calculator["division"](3, 4))
Common errors
We already discussed some caveats we can find while using maps. Lets summarize them:
Nil map panic
Remember, maps need to be initialized using make or by using a composite literal before adding values to them.
var myMap map[string]string
myMap["value"] = "something"
Key not found
Reading a non existing key from a map results in the zero value for the type. It's essential to check for the existence of a key to avoid unexpected behavior in your code.
// key not found
var myMap map[string]string
fmt.Printf("%#v\n", myMap["non-existing-key"])
Maps are reference types
Remember that a map variable doesn't contain de data, but points to its memory location. Failing to account for this can lead to unexpected side effects when passing the map as parameter and this map is modified in the function.
func updateKey(myMap map[string]string) {
myMap["key"] = "some other value"
}
func main() {
var myMap = map[string]string{}
myMap["key"] = "value"
updateKey(myMap)
fmt.Printf("%#v\n", myMap)
}
Maps and concurrent access
This is a more advanced topic I won't delve into this post, but just a word of warning, maps are not safe for concurrent writes (reads are ok). Concurrent safety is important because it ensures that a program behaves correctly when multiple threads are executing concurrently.
You can read more about this here, and if you need to implement concurrent writes, you can use sync.Mutex
Conclusion
In conclusion, maps are incredibly versatile and powerful data structures in Go, offering a convenient way to associate keys with values. Whether you're looking to create data associations, count items, implement a cache, or even switch between functions dynamically, maps have got you covered.
However, as with any tool, it's essential to be aware of common pitfalls, such as nil map panics and concurrent access issues, and to handle them with care. By mastering the art of using maps effectively and understanding their behavior, you'll unlock the full potential of this fundamental data structure in your Go programming journey.
So, go ahead, map out your data, and explore the endless possibilities that maps offer in your Go applications!
Post Script
You can find all the code used for this post in this gist.
Top comments (2)
Map is also a good approach to unique an array
Great article, just about to send to my intern.