Advance Data Types in Go: Arrays, Slices, Maps, Functions

Photo by Chinmay B on Unsplash

Advance Data Types in Go: Arrays, Slices, Maps, Functions

Table of contents

This article builds upon the last entry in the “Golang Primer” series, where we delved into the intricacies of Go syntax and covered fundamental language features, specifically focusing on primary data types in Golang. In this installment, we take a step further into the realm of advanced data types in Golang. Our exploration encompasses the various tools at our disposal for modeling, storing, and accessing data in diverse forms, tailored to different use cases.

Arrays

Arrays in Go serve as ordered collections of items, all of the same type, possessing a fixed size allocated in memory upon creation. Following the convention of many programming languages, array indices commence at zero, indicating that counting starts from 0 rather than 1. In contrast, slices in Go represent a more dynamic alternative to arrays. Slices lack a predetermined length, allowing them to expand or contract based on demand.

Due to the fixed nature of arrays (for instance, if an array of length 3 is created, it can accommodate no more than 3 items), they lack the adaptability required for many applications. To illustrate, adding a fourth item necessitates creating a new array with a larger size, copying the existing items, and then appending the new item. Consequently, slices emerge as the more prevalent choice in Go, and you will seldom encounter direct dealings with arrays.

The syntax for declaring arrays in Go looks as follows:

var fiveNames [5]string

fiveNames = [5]string{"John", "Jean", "Joe", "Jim", "Jane"}

// remember that we can only use the := shorthand inside a function
firstName := fiveNames[0] // to get the first item, John
secondName := fiveNames[1] // to get the second item, Jean

lastName := fiveNames[len(fiveNames)-1) // returns Jane

The len() function, a built-in function in Go, plays a crucial role in array manipulation by returning the length of the array.

Some key aspects distinguish arrays in Go:

  1. Constant Size: The size of an array in Go must be a constant, meaning it must be determinable at compile time.

  2. Type Differentiation by Size: The size of an array is integral to its type. For example, [3]int and [4]int are distinct types.

  3. Syntactic Sugar for Size Inference: Go provides syntactic sugar for inferring the size of an array based on the number of elements. For instance:

     goCopy code
     // This results in the type [5]string
     fiveNames := [...]string{"John", "Jean", "Joe", "Jim", "Jane"}
    
  4. Copy Behavior in Function Arguments: When arrays are passed as arguments to a function, the function receives a copy of the array. Consequently, any modifications or alterations within the function do not impact the original array.

These nuances contribute to the robust and statically typed nature of arrays in Go, ensuring both predictability and type safety in array-based operations. It's essential to recognize that due to their inherent inflexibility, arrays in Go are seldom employed in everyday applications. Instead, slices, a more dynamic and versatile data structure, are the predominant choice in Go programming.

Slices provide a flexible alternative to arrays, adapting in size based on the requirements of the application. This adaptability makes slices better suited for a wide range of use cases, offering more convenience and ease of use compared to arrays.

Slices

Slices, in simple terms, are like arrays but without a fixed size. They're akin to the concept of a List in other popular programming languages. Behind the scenes, slices are built on arrays; they function as pointers to arrays. Unlike arrays, when slices are passed as arguments to a function, the function gets a reference or alias of the slice. This means that any changes made to the passed slice within the function also impact the original copy. It makes sense because a slice is always a pointer to another data structure, so any modifications to that underlying structure should affect anything else referencing or pointing to it. We'll delve into the details of pointers and referencing later.

Creating a slice is as simple as declaring an array without specifying a fixed size:

// note that the [] is empty, that is indicating that this is a slice
var sliceofNames []string

sliceOfNumbers := []int{0,1,2,3,4,5}

These are some common operations on slices:

// To get an item at an index, remember that the indexing is 0 based

zero := sliceOfNumbers[0]
one := sliceOfNumbers[1]

// this will create a new slice with items 2,3,4 and 5.
// The operation follows the format s[i:j], where i is the start index 
// and j is the end index. Note that the operator will only get up to 
// the j-1 element and not include the end index in the resulting slice, 
// which is why we use 6 in the example to get the 6-1 index, 
// which is the index of number 5
twoTo5 := sliceOfNumbers[2:6]

// we can also achieve the same result as above, 
// if the end index is skipped, as in this case, it defaults to len(s)
// i.e the lenght of the slice, so everything till the end is included
twoTo5 := sliceOfNumbers[2:]

// if the start index is ignored, then the index 0 is used by default
// i.e the new slice begins at the first index
zeroToFour := sliceOfNumbers[:5]

Slices in Golang differ from some other languages, notably in the aspect of comparison using ==. To compare two slices, it's necessary to manually compare each item in the array.

This is where the similarities between Golang slices and lists in other languages largely end. Tasks that may be familiar, such as add, pop, and insert, often require more manual effort in Golang.

Let's start with the task of adding an item to a slice. Golang provides a built-in append function precisely for this purpose. The append function takes a slice and a variadic number of arguments, allowing flexibility in the number of arguments passed—whether it be one, two, or an infinite amount.

newSlice := append(oldSlice, newItem)

// the append function has a variadic parameter, meaning we can pass 
// any amount of arguments after the slice
newSlice := append(oldSlice, newItem1, newItem2, newItem3, ...)

// or even append a slice to another
newSlice = append(slice1, slice2)

Removing an item from the slice is slightly more complicated:

names := []string{"John", "Paul", "George", "Ringo"}

// remove the first element
names = names[1:]

// remove the last element
names = names[:len(names)-1]

// remove the nth element in a slice
// note that we created an anonymous function and assigned it to a variable
removeNth := func(s []string, i int) []string {
        // check if the index is not out of range 
        if i >= len(s) {
            return s
        }
        /// note the ..., i like to call it the spread operator
        return append(s[:i], s[i+1:]...)
}

names = removeNth(names, 10)

To remove the first item from a list (or slice, pun intended), we simply create a new slice starting from the second index to the end of the list and assign it to the original slice. It's worth noting that if the end index is not specified after the colon, it defaults to the length of the list.

For removing the last item, a similar technique is employed. We copy from the start index up to the index of the last item, as illustrated in the example. Removing an item at a specific index, say the nth index, involves creating two slices: one from the start index up to the index to be removed, and another from after the index to be removed. These slices are then stitched together using the append function. The ... syntax serves as a spread operator, allowing all elements of the items in the slice to be spread and passed to the append function. This is possible because the append function also accepts variadic inputs.

To iterate over a slice, Golang provides a built-in range method. This method iterates over the slice, returning the next item on each iteration, starting from the first item.

// the range returns to result, the first is the index, the second is the value
// since we don't need the index in this example, we ignore it with the _ syntax
for _, name := range names {
        fmt.Println(name)
}

// or if you prefer, you can use this common way
for i := 0; i < len(names); i++ {
        fmt.Println(names[i])
}

Maps

In Go, a map is essentially a reference to a hash table. A hash table is a type of data structure used for an unordered collection of key/value pairs. Each value in the collection is associated with a unique key, and this association is made possible by a function known as a "hash" function. Importantly, all keys within the hash table are unique and are created using the hash function.

Hash tables are significant and valuable data structures, and I would strongly recommend delving deeper into understanding them. They play a crucial role in efficiently organizing and retrieving data based on unique keys, making them a cornerstone in various applications and algorithms.

You can think of a map in Go as an unordered slice where we get to decide the key by which we want to identify an item, with the condition that the keys must be comparable with the == syntax. A map has the below syntax, where the K is the type of the key, and V is the type of the value. The keys must have the same type, i.e you cannot use a string as key for one item and then use int it for another, and the values must also be of the same type.

var newMap map[K]V

// using the := syntax
mapOfAlphabetsToLanguages := map[string]string{
    "A": "Ada",
    "B": "BASIC",
    "C": "C++",
    "D": "Dart",
    "E": "Erlang",
}

// or using the built in make function
newMap := make(map[string]int)

You might be wondering why we would use the make function when we already have the convenient := syntax. The reason lies in situations where we know the size of the map in advance. By specifying the size of the map using the make function, like this: make(map, sizeOfMap), we can optimize resource usage and allocate an appropriately sized map from the start. This can be particularly beneficial when working with large datasets or in scenarios where efficiency and resource management are critical considerations.

These are some common actions we can take on a map:

mapOfAlphabetsToLanguages := map[string]string{
    "A": "Ada",
    "B": "BASIC",
    "C": "C++",
    "D": "Dart",
    "E": "Erlang",
}

// we can get the value by passing the key, 
ada := mapOfAlphabetsToLanguages["A"] // note that this is case-sensitive

// if we do this, there is no "a" key in the map, so the zero based value 
// for the type is returned, which is an empty string "" for strings.
ada2 := mapOfAlphabetsToLanguages["a"]

// we can reassign the values of a key,
// the value of the key "A" is now "ActionScript"
mapOfAlphabetsToLanguages["A"] = "ActionScript"

// we can use the builtin delete function to delete a key-value pair from the map
// the key "A" is now deleted from the map
delete(mapOfAlphabetsToLanguages, "A")

// we can use the len function to get the length of the map
// the length of the map is 4
lengthOfMap := len(mapOfAlphabetsToLanguages)

// we can use the range keyword to iterate over the map
// the order of the iteration is not guaranteed
for key, value := range mapOfAlphabetsToLanguages {
        fmt.Println(key, value)
}

As demonstrated in the example code above, attempting to retrieve a key that is not defined in the map does not result in an error; instead, you receive the zero value of that type. However, there are cases where it's crucial to ascertain whether the key truly exists in the map, and Go provides a convenient way to do this:

goCopy code
value, ok := theMap["wrongKey"]

Here, the variable ok is a boolean that is true when the key is present and false when the key is not found in the map. This mechanism proves helpful in handling scenarios where the existence of a key needs to be validated. It's worth noting that the name ok is a convention, and you can choose any variable name, such as notOk; however, using ok is a common practice for readability and consistency.

So in what cases should you use a slice or a map? The choice between using a slice or a map in Go depends on the specific requirements of your data structure. Here are some guidelines to help you decide:

Use a Slice if:

  1. You need an ordered collection.

  2. The order of elements matters, and you want to preserve it.

  3. Indexing is essential, and you care about the position of elements.

  4. You are frequently shuffling or manipulating the collection.

Use a Map if:

  1. The collection does not need to be ordered.

  2. You require key/value pairs, where each element has a unique and comparable key.

  3. Absolute control over indexing is not critical, and you can use the key for retrieval.

  4. You need to check for the existence of a particular key efficiently.

For example, you might store all student details in a slice if the order of entry is important. On the other hand, if you need to associate each student with a unique identifier (like a student ID) and their corresponding grade, a map would be a more suitable choice. This provides a key/value relationship, making it efficient to retrieve grades based on student IDs. The decision ultimately hinges on the specific characteristics and use cases of your data.

Functions

A function in Golang serves as a reusable unit of code designed to accomplish a specific task. It enables us to break down a large task into more manageable components that can be abstracted away from users, facilitating repetitive actions without the need to rewrite the implementation every time. The basic syntax of a function in Golang is as follows:

func functionName(parameters) (results) {
    ...
    // body
    ...
}

A function is named, which can be anything descriptive except for reserved keywords. It is followed by a pair of brackets ( and ) that contain the parameters the function can accept and work with. After that comes the result types, defining the types of outcomes returned to the function invoker. The function body is enclosed in a pair of curly brackets { }.

Let us write a function that gives us the sum of a list of integers:

func sum(numbers []int) int {
    sum := 0
    for _, number := range numbers {
        sum += number
    }
    return sum
}

This sum function takes a slice of integers ([]int) as a parameter and returns an integer.

  • Inside the function, we initialize a variable sum to 0. Note that the variable name (sum) is arbitrary, but it's good practice to choose a name that reflects the variable's purpose.

  • Utilizing the range method, we iterate over the slice to access individual numbers.

  • For each number in the slice, we add it to the sum.

  • After the loop completes, we use the return keyword to send the result back to the invoker of the function. The invoker is essentially the entity that is called the function.

We can then use the function in our code like this:

listOfNumbers := []int{1, 2, 3, 4, 5}

sum1 := sum(listOfNumbers)

listOfNumbers2 := []int{584, 392, 2834, 83, 4839}

sum2 := sum(listOfNumbers2)

Consider another example function:

func sumAndCount(nums ...int) (int, int) {
    sum := 0
    count := 0

    for _, num := range nums {
        sum += num
        count++
    }

    return sum, count
}

This function, named sumAndCount, is similar to the previous one but with an added feature. Instead of only calculating the sum, it also returns the count of elements in the provided slice.

Take note of a couple of key features:

  • The function employs a variadic parameter denoted by ... before the type (...int), indicating that the function can accept any number of int parameters.

  • The result syntax is now (int, int), indicating that the function returns two values, both of type int: the sum and the count.

sum1, count1 := sumAndCount(1, 2, 3, 4, 5)

listOfNumbers2 := []int{584, 392, 2834, 83, 4839}

// we use the spread operator to `spread` the content of the slice to the function
sum2 := sumAndCount(listOfNumbers2...)

Here's another example demonstrating an alternative function syntax:

// divideAndRemainder divides two numbers and 
// returns the quotient and remainder.
func divideAndRemainder(dividend, divisor int) (quotient, remainder int) {
    quotient = dividend / divisor
    remainder = dividend % divisor
    return // This return statement implicitly returns the named values (quotient, remainder)
}
  • This function takes two parameters, dividend, and divisor, both of type int. As they share the same type, Go allows us to omit the types for all but the last one.

  • The primary focus of this function is on the results. Yes, these are named results. Golang permits us to name the results, which can be advantageous in various scenarios. Named results act like variables; they are initialized with their zero values and are assignable within the function, hence the use of = in assignments like quotient = dividend / divisor, instead of the := syntax.

  • Notice the absence of an explicit return statement? That's because the results are named, and Go automatically returns them from the function. The return statement at the end of the function implicitly returns the named values (quotient, remainder).

Named Arguments and Default Values in Golang

If you observe, when invoking functions in Go, we don't use named arguments. In some languages, invoking the divideAndRemainder function might look like this:

// Name arguments in some languages
divideAndRemainder(dividend: 17, divisor: 3)

However, in Go, there is no concept of named arguments. All arguments must be passed in the same order as the parameters are declared in the function:

// Go has no concept of named arguments
divideAndRemainder(17, 3)

Additionally, it's crucial to note that Golang lacks the concept of default values. In Go, all parameters declared in a function must be explicitly passed as arguments when invoking the function. This simplicity and explicitness contribute to the clarity and predictability of Go code.

Anonymous Functions in Go

Anonymous functions, as showcased in the example, are functions in Go that don't have a name. In Go, it's not allowed to create a named function within another function. However, there are scenarios, like the one presented here, where a function is only useful in the current context, and in such cases, we can resort to using anonymous functions.

Here's an example:

names := []string{"John", "Paul", "George", "Ringo"}

// Remove the first element
names = names[1:]

// Remove the last element
names = names[:len(names)-1]

// Remove the nth element in a slice
// Note that we created an anonymous function and assigned it to a variable
removeNth := func(s []string, i int) []string {
    // Check if the index is not out of range
    if i >= len(s) {
        return s
    }
    // Use the "spread" operator (...) to remove the element at index i
    return append(s[:i], s[i+1:]...)
}

names = removeNth(names, 10)

Anonymous functions are declared using the func keyword, similar to named functions, but without including a name. They prove useful when you need to group a block of code together, especially in cases where the function is only intended to be executed once or when defining a function within another function is necessary, as seen in callbacks.

Here are additional examples to deepen our understanding:


import "fmt"

func main() {
    // Example 1: Anonymous function assigned to a variable
    add := func(x, y int) int {
        return x + y
    }

    result1 := add(3, 5)
    fmt.Printf("Result 1: %d\\n", result1)

    // Example 2: Immediately invoked anonymous function
    result2 := func(a, b int) int {
        return a * b
    }(4, 6) // Note that this function is called immediately with ()

    fmt.Printf("Result 2: %d\\n", result2)

    // Example 3: Passing an anonymous function as an argument
    calculate := func(operation func(int, int) int, x, y int) int {
        return operation(x, y)
    }

    result3 := calculate(func(a, b int) int {
        return a - b
    }, 8, 3)

    fmt.Printf("Result 3: %d\\n", result3)
}
  1. Example 1 demonstrates an anonymous function assigned to the variable add. This function adds two integers.

  2. Example 2 shows an immediately invoked anonymous function. The function is defined and called in a single line to calculate the product of two numbers. Notice the function invocation () immediately following the function block {}.

  3. Example 3 showcases passing an anonymous function as an argument to another function (calculate). The calculate function takes an operation and two integers, applying the operation to the integers. This example demonstrates how to parameterize the calculate function, allowing the invoker to supply a function fitting the specified interface.

Congratulations! You have made it to the end of this session As we conclude this session, we've delved into the fundamental aspects of Golang, exploring syntax intricacies, understanding data types, and delving into the world of functions, including the intriguing concept of anonymous functions.

However, our journey into Golang is far from over. In the forthcoming segments of the "Golang Primer," we are poised to explore advanced data types, delving into the nuances of structs, interfaces, pointers, and the critical domain of error handling in Golang.

Please feel free to drop any questions you have and I will be happy to answer them. You can connect with me via my email intellectualjemeel@gmail.com, I am here on LinkedIn and here on Twitter, let’s connect.