Golang Grammar Quiz vol.1: Slice

Photo by Evan Dennis on Unsplash

Golang Grammar Quiz vol.1: Slice

Yesterday, I came across a recording from the 2018 GopherCon (GopherCon 2018: Jon Bodner - Go Says WAT). In this presentation, Jon Bodner delves into some of the brain-twisting behaviors of Golang.

While the content was intriguing, I must admit that I struggled to comprehend a significant portion of his explanations because he speaks very fast in the video.

In this post, I aim to break down his specific explanation about slices. If you, too, found it challenging to grasp the content from the video, perhaps this post can offer you a clearer understanding.

Even if you haven't watched the video yet, that's perfectly fine – let's explore the fascinating behaviors of Golang together.

Question 1

In Golang, a slice is a reference type, which is like a pointer, and can be modified by the called function, as shown below:

package main

import "fmt"

func main() {
    s := []int{1, 3, 5}
    multiplyBy3(s)
    fmt.Println(s)
}

func multiplyBy3(s []int) {
    for i, v := range s {
        s[i] = v * 3
    }
}
// Output:
// [3, 9, 15]

But what happens if we append some numbers to the slice in the called function?

package main

import "fmt"

func main() {
    a := []int{1, 3, 5}
    appendNums(a)
    fmt.Println(a)
}

func appendNums(s []int) {
    s = append(s, 2, 4)
}

What do you think the output of the above code is?

A. Does not compile

B. Panics

C. Prints [1, 3, 5]

D. Prints [1, 3, 5, 2, 4]

Answer and Explanation

The answer is C. Prints [1, 3, 5].

We can see what's going on by printing the address of the slice and its elements.

package main

import "fmt"

func main() {
    s := []int{1, 3, 5}
    fmt.Printf("before appendNums: \taddress:%p\tcap:%d\tvalue:%v\n", s, cap(s), s)
    appendNums(s)
    fmt.Printf("after appendNums: \taddress:%p\tcap:%d\tvalue:%v\n", s, cap(s), s)
}

func appendNums(s []int) {
    fmt.Printf("start of appendNums: \taddress:%p\tcap:%d\tvalue:%v\n", s, cap(s), s)
    s = append(s, 2, 4)
    fmt.Printf("end of appendNums: \taddress:%p\tcap:%d\tvalue:%v\n", s, cap(s), s)
}

// Output:
// before appendNums:    address:0xc000112000    cap:3    value:[1 3 5]
// start of appendNums:    address:0xc000112000    cap:3    value:[1 3 5]
// end of appendNums:    address:0xc000126000    cap:6    value:[1 3 5 2 4]
// after appendNums:    address:0xc000112000    cap:3    value:[1 3 5]

Evidnetly, at the end of the appendNums function, the address of the variable s differs from the one at the beginning of the function.

This is because the built-in append function creates a new slice with double the capacity of the original slice and copies the elements of the original slice to the new slice. Consequently, the original slice remains unmodified.

Question 2

We've just learned that a new slice gets created when we append elements to a slice that doesn't have enough capacity.

But what if we pass a slice with enough capacity to the called function?

package main

func main() {
    s := make([]int, 0, 10) // create a slice with length 0, capacity 10
    s = append(s, 1, 2, 3)
    appendNums(s)
    fmt.Println(s)
}

func appendNums(s []int) {
    s = append(s, 7, 8)
}

Which of the following options will be the output of the above code?

A. Does not compile

B. Panics

C. Prints [1, 2, 3]

D. Prints [1, 2, 3, 7, 8]

Answer and Explanation

The answer is C. Prints [1, 2, 3].

To understand why, let's print the address of the slice and its elements again.

package main

import "fmt"

func main() {
    s := make([]int, 0, 10) // create a slice with length 0, capacity 10
    s = append(s, 1, 2, 3)
    fmt.Printf("before appendNums: \taddress:%p\tcap:%d\tvalue:%v\n", s, cap(s), s)
    appendNums(s)
    fmt.Printf("after appendNums: \taddress:%p\tcap:%d\tvalue:%v\n", s, cap(s), s)
}

func appendNums(s []int) {
    fmt.Printf("start of appendNums: \taddress:%p\tcap:%d\tvalue:%v\n", s, cap(s), s)
    s = append(s, 7, 8)
    fmt.Printf("end of appendNums: \taddress:%p\tcap:%d\tvalue:%v\n", s, cap(s), s)
}
// Output:
// before appendNums:    address:0xc000018050    cap:10    value:[1 2 3]
// start of appendNums:    address:0xc000018050    cap:10    value:[1 2 3]
// end of appendNums:    address:0xc000018050    cap:10    value:[1 2 3 7 8]
// after appendNums:    address:0xc000018050    cap:10    value:[1 2 3]

It might be confusing to see they share the same address, yet the slice is not modified after appendNums, isn't it?

I've mentioned that the slice object can be treated like a pointer, but it's not exactly the same as a pointer.

In essence, what appears to be the pointer of the slice is not the pointer of the slice itself, but the pointer of the underlying array.

Let me explain a bit more.

First, let's examine the definition of the slice object.

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

A slice is composed of three fields: array, len, and cap.

It's clear that len and cap hold the length and capacity of the slice, while array is the unsafe pointer to the underlying array. Using the unsafe.Pointer type for array allows slices to hold any type of elements.

Golang is a call-by-value language (not call-by-reference), meaning it always copies the value of the argument to the parameter of the called function.

Consequently, it is possible to modify the value from the original slice because the address of the underlying array is shared between the original slice and the slice in the called function.

However, when we add elements to the slice in the called function, the addresses of the added elements are no longer shared with the original slice simply because the original slice does not have a reference to the added elements.

Let's examine one more piece of code to understand this more clearly.

package main

import "fmt"

func main() {
    s := make([]int, 0, 10)
    s = append(s, 1, 2, 3)
    appendNums(s)
    fmt.Println(s)
}

func appendNums(s []int) {
    s = append(s, 7, 8)
    s[1] = 100 // added line
}
// Output:
// [1 100 3]

As you can see, the value of the second element of the original slice is modified from 2 to 100 because the address of the second element is shared between the original slice and the slice in the called function. However, the original slice does not have references to the added elements(7 and 8), so 7 and 8 are not printed at the end.

If you want to both modify and lengthen the original slice, you can pass the pointer of the slice to the called function.

package main

import "fmt"

func main() {
    s := make([]int, 0, 10)
    s = append(s, 1, 2, 3)
    appendNums(&s) // pass the pointer of the slice
    fmt.Println(s)
}

func appendNums(s *[]int) { // receive the pointer of the slice
    *s = append(*s, 7, 8) // dereference the pointer to modify the original slice
}
// Output:
// [1 2 3 7 8]

Wrap Up

In Jon Bodner's presentation, he explores various other interesting behaviors of Golang. I look forward to breaking them down in future posts.

If you haven't watched the video yet, I encourage you to check it out. It's a bit mind-bending for me, but I'm sure you'll find it enjoyable as well.

Happy coding!