Go Slices: Everything You Need to Know

Published
61

I have been working as a software developer for 4 years, actually, I worked with .NET most of the time, but I primarily worked with Microsoft .NET technology.

Last year, I decided to switch to Go because of its performance and simplicity, and because of this reason, many big products and startups choose to use Go, while .NET is mostly used in outsourcing companies.

To solidify my knowledge and learn more about the language, writing blogs is a good way. Therefore, I will start to write about slices in Go, a topic that I believe is popular and important for all Go developers to understand.

Array In Go

Go do have Array, like the most of programming languages in the world. But you will find that almost no one uses arrays in Go programming because array is a rigid composite type.

Declaring an Array

First of all, we should learn about array, which is the core data type in Go, and the data type behind Slices.

var a [5]int
a[4] = 99
fmt.Println(a)
// result: [0 0 0 0 99]

In the code above, I declared an array of integers with a length of 5, then assign the last element to 4.

Go uses square brackets, the same syntax as other languages to read and assign values from an array.

And the first 3 elements of the array a are set to 0 because Go will initialize an array with the zero values of its type. With the integer, the zero value is 0.

I don’t use the := declaration syntax here, I will talk about it in the next section.

Of course, we can initialize the values of an array to avoid zero values:

var fiveElements = [5]string{"Metal", "Wood", "Water", "Fire", "Earth"}
// We can even specify the index: 
var numbers = [5]int{0: 1, 4: 9}
// numbers should be [1 0 0 0 9]

Comparing two arrays

Nah, We have a difference between Go and some OOP languages (like C#): We can use == and != operators to compare them.

See this example:

var fiveElements = [5]string{"Metal", "Wood", "Water", "Fire", "Earth"}
var fiveElementsCopy = [5]string{"Metal", "Wood", "Water", "Fire", "Earth"}
fmt.Println(fiveElements == fiveElementsCopy)

The result will be true! If you implement this example in other languages, you will receive the falsy result. You can try it in this fiddle!

Comparing two arrays in C# will return false!

Array types are comparable if their array element types are comparable.

Two array values are equal if their corresponding element values are equal.

The Go Programming Language Specification: Comparison operators

The limitations

The problem with using arrays in Go is that we cannot declare the size of an array dynamically:

The length is part of the array’s type; it must evaluate to a non-negative constant representable by a value of type int

https://go.dev/ref/spec#Array_types

Go recognizes size a part of the array’s type.

This means that you can not use a variable to specify the size of an array.

Because a variable declared as [2]string will have a different type from another declared with [3]string. It is divergent from the rest of the coding languages!

Seems it is relevant to Go alias type mechanism, we will talk about it later.

We cant use dynamic size for Go arrays.

Mastering the Basics of Slices in Go

You know what they say, everything happens for a reason, and Go’s arrays are no exception! They are the cornerstone of building solid data structures in Go, and you can’t talk about arrays without mentioning slices.

Slice is a superior structure to array, and of course, Array stands behind a Slice.

Declare a slice

The first difference between array and slice now appears – the declaration.

With slice, we don’t need to specify the size because it is a dynamically-sized type.

var numbers = []int{2,4,6}

The above line will create a slice of 3 integers named numbers . And just like Array, we can also initialize the values specifically with their indices:

var numbers = []int{1:2, 3:4, 5: 6}
// result: [0, 2, 0, 4, 0, 6]

Slice zero value

If we just declare a slice without initializing values, it will be assigned the zero value: nil.

var numbers []int
fmt.Println(numbers == nil) // true

Don’t make a mistake at this, because the zero value of the slice itself is not the zero value of its elements.

Slice structure

I made a sketch about slice structure:

Illustrate how slice is implemented in Go

You can see, the main part of a slice in Go is an array and think of the slice as just a wrapper.

It contains 3 things in the header:

  • pointer to the data
  • length
  • capacity

See the source code of it (check the original source):

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

Basic methods

We have some basic methods to work with Go:

If you want to declare a slice with a specific capacity and size, use make() .

In addition, respectively, len() and cap() methods can be used to check the size and capacity of the slice.

// create a slice with capacity 5 and size 3
s := make([]int, 3, 5)

// add some elements to the slice
s[0] = 1
s[1] = 2
s[2] = 3

// print the slice, length, and capacity
fmt.Println("slice:", s)
fmt.Println("length:", len(s))
fmt.Println("capacity:", cap(s))
// !! assign to index out of range will cause runtime error:
s[3] = 4

The result:

slice: [1 2 3]
length: 3
capacity: 5

From Go Tour:
– Inside a function, the := short assignment statement can be used in place of a var declaration with implicit type.
– Outside a function, every statement begins with a keyword (var, func, and so on) and so the := construct is not available.

Remember that if you assign a new element at the index that is out of range ( > length – 1), you will get a runtime error. Instead, you should use the append method in that case.

Append new elements

To append a single element or multiple elements into a slice, use append():

x := []int{1, 2, 3}
x = append(x, 4)
x = append(x, 5, 6, 7)

append() is a variadic function in Go, which means that it can be called with any number of trailing arguments (like fmt.Println()). If you used Javascript before, I can say that in Go we have a similar syntax with “spread syntax” for variadic functions by using three dots ... :

x := []int{1, 2, 3}
y := []int{4,5,6}
x = append(x, y...)

Remember that you have to assign the return value from append function, whether you will get a compile-time error.

Iterating over a slice

To loop through a slice in Go, you can use for range:

x := []string{"Tony", "Stark", "Hulk", "Thor", "Panther"}

for i, v := range x {
	fmt.Printf("%d: %v\n", i, v)
}

If you don’t need the index, replace the first variable after for by underscore _ :

x := []string{"Tony", "Stark", "Hulk", "Thor", "Panther"}

for _, v := range x {
	fmt.Printf("%v\n", v)
}

Go Slices in Advance: What You Need to Know

Slice capacity growing

You may ask what will happen if we append more elements into a slice that is already at full capacity.

Let’s check this example:

func main() {
	x := []int{1, 2, 3}
	fmt.Println(x)
	fmt.Printf("cap: %d, len: %d\n", cap(x), len(x))

	// append more
	x = append(x, 1)
	fmt.Printf("cap: %d, len: %d\n", cap(x), len(x))
}

The result:

[1 2 3]
cap: 3, len: 3
cap: 6, len: 4

You can see that in line 4, capacity was 3 and size was fully at 3 too. But I appended one more element, and the capacity just got doubled in value.

When you call append on a slice that is at full capacity, Go will automatically create a new underlying array with double the capacity of the old array. It will then copy the existing elements from the old array to the new one, along with any new elements you added. This ensures that your slice can always accommodate new elements without running into capacity issues.

Go slices grow by doubling until size 1024, after which they grow by 25% each time [source]

Best practice of using make with slices

Using make to create a new slice is an efficient way, but it is important how you specify the initial length and/or capacity.

In this case, you know the size of the slice exactly, you just need to specify the length only:

products := getLatestProducts()

// for each product, create an image
images := make([]Image, len(products))
// loop...

Or in another case, you need to specify not only length but also a buffered capacity number to ensure that the slice would not create a new underlying array:

products := getLatestProducts()

// for premium product, add an cover image
// for each product, create an image
images := make([]Image, len(products), len(products)*2)

Comparing two slices

The operators == and != can be used to compare arrays, but not slices.

To compare two slices, we can use some other ways.

1. Use a custom method that loops all elements in the slice

func main() {
	arr1 := []string{"a", "b"}
	arr2 := []string{"a", "b"}
	fmt.Println(Equal(arr1, arr2)) // true
}

// Equal tells whether a and b contain the same elements.
// A nil argument is equivalent to an empty slice.
func Equal[T comparable](a, b []T) bool {
	if len(a) != len(b) {
		return false
	}
	for i, v := range a {
		if v != b[i] {
			return false
		}
	}
	return true
}

The Equal method I provided above just can work with comparable types only.
Here are some examples of types that are comparable in Go:
– Numeric types (int, float, etc.)
– String
– Bool
– Pointers
– Structs containing only comparable types

2. Use reflect.DeepEqual

DeepEqual is a function in Golang that checks if two values are the same in a deep sense. This means that it not only compares values of the same type, but also arrays, structs, interfaces, maps, pointers, and slices.

If the two values being compared are not of identical types, then they are never deeply equal.

If a cycle is detected while comparing values, DeepEqual treats the values as equal instead of examining them further.

However, there are certain values that cannot be compared in general, such as NaN values and values containing them, or func values. (Check playground)

import (
	"fmt"
	"reflect"
)

type Person struct {
	ID string
}

func main() {
	a := []Person{{ID: "A"}}
	b := []Person{{ID: "B"}}
	c := []Person{{ID: "A"}}
	fmt.Println(reflect.DeepEqual(a, b)) // false
	fmt.Println(reflect.DeepEqual(a, c)) // true
}

Slicing a slice

Syntax creating a slice from a slice is called slice expression.

A slice is formed by specifying two indices, a low and high bound, separated by a colon [ref]:

a[low : high]

low is a zero-based starting index, the default value if you leave off this will be 0.

high is the stop index of slicing (not included), if you ignore it, the end of the slice will be assumed.

For example:

primes := []int{2, 3, 5, 7, 11, 13}

s := primes[1:4]
s2 := primes[:4]
s3 := primes[2:]

fmt.Println(s)  // [3, 5, 7]
fmt.Println(s2) // [2, 3, 5, 7]
fmt.Println(s3) // [5, 7, 11, 13]

Slices share the same underlying array

A slice does not store any data, it just describes a section of an underlying array.

Changing the elements of a slice modifies the corresponding elements of its underlying array.

Other slices that share the same underlying array will see those changes.

The Go Tour

Check this example:

x := []string{"Tony", "Stark"}
y := x
y[1] = "Iron Man"
fmt.Println(x, y)

You can easily guess the output, it should be [Tony Iron Man] [Tony Iron Man]. The values are linked.

In addition, when you slice a slice, you’re not copying the data – you’re basically creating two slices that use the same data underneath.

See the example below, the element “best” is changed in both slices x and y.(Check it out at playground here)

x := []string{"Vietnam", "is", "the", "best"}
y := x[2:4]
fmt.Printf("x: %s. y: %s\n", x, y)
y[1] = "second"
fmt.Printf("x: %s. y: %s\n", x, y)
// result:
// x: [vietnam is the best]. y: [the best]
// x: [vietnam is the second]. y: [the second]

What would happen if we combine this with append?

x := []string{"I", "am", "a", "gopher"}
y := x[:2]
fmt.Printf("cap x: %d, cap y: %d\n", cap(x), cap(y))
fmt.Printf("x: %v, y: %v\n", x, y)
y = append(y, "CUTE")
fmt.Println("x:", x)
fmt.Println("y:", y)

Results (playground):

cap x: 4, cap y: 4
x: [I am a gopher], y: [I am]
x: [I am CUTE gopher]
y: [I am CUTE]

The output is so weird.

In simpler terms, when you create a slice derived from an original slice, any changes made to the “derivative” slice will also affect the original slice if the capacity of the derived slice is not exceeded.

However, if you append new elements to the derived slice that would increase its capacity, it will occupy a different location in memory. As a result, any changes made to the derived slice after this point will not affect the original slice it was derived from.

So we should avoid using this sh!t.

Copy

We can use copy to clone the values from one slice to another without linking the underneath array (check playground):

x := []string{"I", "am", "a", "gopher"}
z := make([]string, 4)
copy(z, x)
z = append(z, "NEW")
fmt.Println("x:", x)
fmt.Println("z:", z)

Results:

x: [I am a gopher]
z: [I am a gopher NEW]

You can find the definition of it here

Conclusion

Overall, mastering the basics of slices in Go is crucial for any developer working with arrays. By understanding how to declare, append, and compare slices, as well as how to grow slice capacity and handle underlying array sharing, you can write more efficient and effective code. And with the right approach to slicing and copying, you can avoid common mistakes and pitfalls. So whether you’re a beginner or a seasoned pro, taking the time to learn and practice working with Go slices will pay off in the long run.

Common questions

Does Go have multi-dimensional array?

No, Go only has one-dimensional array, but you can simulate multi-dimensional arrays by declaring sort of: var x [2][4]int

How to concatenate two slices?

You just need the append function with variadic params:

second := append(x, y...)

Some relevant resources are linked in this post, you can check those links. And my article got inspired by this book: Learning Go – Jon Bodner