Introduction to Maps in Golang

makko

Héctor Vela

Posted on September 13, 2023

Introduction to Maps in Golang

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.

A programmer working with hash maps as pictured by Dall-e 2

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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",
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

We can obtain values from a map by specifying the key for the data we want.

devAccount := account["dev"]
fmt.Printf("%q\n", devAccount)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

Delete a map entry by key:

key := "dev"
delete(account, key)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
makko
Héctor Vela

Posted on September 13, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Introduction to Maps in Golang
go Introduction to Maps in Golang

September 13, 2023