Home > Backend Development > Golang > Arrays vs Slices in Go: Understanding the 'under the hood' functioning visually

Arrays vs Slices in Go: Understanding the 'under the hood' functioning visually

Patricia Arquette
Release: 2024-12-21 18:27:15
Original
275 people have browsed it

Arrays vs Slices in Go: Understanding the

Have you ever tried packing for a trip without knowing how long you'll be there? That's precisely what happens when we store data in Go. Sometimes, like when packing for a weekend trip, we know exactly how many things we need to store; other times, such as, when packing for a trip where we say, "I'll return when I'm ready," we don't.

Let's take a deep dive into the world of Go arrays and slice internals through simple illustrations. We will look into:

  1. Memory layouts
  2. Growth mechanisms
  3. Reference semantics
  4. Performance implications

By the end of this read, you'll be able to understand when to use arrays versus when to use slices with the help of real world examples and memory diagrams

Arrays: The Fixed-Size Container ?

Think of an array as a single block of memory where each element sits next to each other, like a row of perfectly arranged boxes.

When you declare var numbers [5]int, Go reserves exactly enough contiguous memory to hold 5 integers, no more, no less.

Arrays vs Slices in Go: Understanding the

Since they have contiguous fixed memory it can't be sized during runtime.

func main() {
    // Zero-value initialization
    var nums [3]int    // Creates [0,0,0]

    // Fixed size
    nums[4] = 1       // Runtime panic: index out of range

    // Sized during compilation
    size := 5
    var dynamic [size]int  // Won't compile: non-constant array bound
}
Copy after login
Copy after login
Copy after login

Arrays vs Slices in Go: Understanding the

The size is part of the array's type. This means [5]int and [6]int are completely different types, just like int and string are different.

func main() {
    // Different types!
    var a [5]int
    var b [6]int

    // This won't compile
    a = b // compile error: cannot use b (type [6]int) as type [5]int

    // But this works
    var c [5]int
    a = c // Same types, allowed
}
Copy after login
Copy after login
Copy after login

Why Array is Copy By Default?

When you assign or pass arrays in Go, they create copies by default. This ensures data isolation and prevents unexpected mutations.

Arrays vs Slices in Go: Understanding the

func modifyArrayCopy(arr [5]int) {
    arr[0] = 999    // Modifies the copy, not original
}

func modifyArray(arr *[5]int){
    arr[0] = 999  // Modifies the original, since reference is passed
}

func main() {
    numbers := [5]int{1, 2, 3, 4, 5}

    modifyArrayCopy(numbers)
    fmt.Println(numbers[0])  // prints 1, not 999

    modifyArray(&numbers)
    fmt.Println(numbers[0])  // prints 999
}
Copy after login
Copy after login
Copy after login

Slices

Alright so you can't do var dynamic [size]int to set dynamic size, this is where slice comes into play.

Slices under the hood

The magic lies in how it maintains this flexibility while keeping operations fast.

Every slice in Go consists of three critical components:

Arrays vs Slices in Go: Understanding the

type slice struct {
    array unsafe.Pointer // Points to the actual data
    len   int           // Current number of elements
    cap   int           // Total available space
}
Copy after login
Copy after login
Copy after login

What's unsafe.Pointer??

The unsafe.Pointer is Go's way of handling raw memory addresses without type safety constraints. It's "unsafe" because it bypasses Go's type system, allowing direct memory manipulation.

Think of it as Go's equivalent to C's void pointer.

What's that array?

When you create a slice, Go allocates a contiguous block of memory in the heap (unlike arrays) called backing array. Now the array in slice struct points to the start of that memory block.

The array field uses unsafe.Pointer because:

  1. It needs to point to raw memory without type information
  2. It allows Go to implement slices for any type T without generating separate code for each type.

The dynamic mechanism of slice

let's try developing intuition for the actual algorithm under the hood.

Arrays vs Slices in Go: Understanding the

If we go by intuition we can do two things:

  1. We could set aside space so large and can use it as and when required
    pros: Handles growing needs till a certain point
    cons: Memory wastage, practically might hit limit

  2. We could set a random size initially and as the elements are appended we can reallocate the memory on each append
    pros: Handles the previous case, can grow as per the need
    cons: reallocation is expensive and on every append it's going to get worst

We cannot avoid the reallocation as when the capacity hits one needs to grow. We can minimize the reallocation so that the subsequent inserts/appends cost is constant (O(1)). This is called amortized cost.

How can we go about it?

till Go version v1.17 following formula was being used:

func main() {
    // Zero-value initialization
    var nums [3]int    // Creates [0,0,0]

    // Fixed size
    nums[4] = 1       // Runtime panic: index out of range

    // Sized during compilation
    size := 5
    var dynamic [size]int  // Won't compile: non-constant array bound
}
Copy after login
Copy after login
Copy after login

from Go version v1.18:

func main() {
    // Different types!
    var a [5]int
    var b [6]int

    // This won't compile
    a = b // compile error: cannot use b (type [6]int) as type [5]int

    // But this works
    var c [5]int
    a = c // Same types, allowed
}
Copy after login
Copy after login
Copy after login

since doubling a large slice is waste of memory so as the slice size increases the growth factor is decreased.

Let's get a better understanding from usage perspective

Arrays vs Slices in Go: Understanding the

func modifyArrayCopy(arr [5]int) {
    arr[0] = 999    // Modifies the copy, not original
}

func modifyArray(arr *[5]int){
    arr[0] = 999  // Modifies the original, since reference is passed
}

func main() {
    numbers := [5]int{1, 2, 3, 4, 5}

    modifyArrayCopy(numbers)
    fmt.Println(numbers[0])  // prints 1, not 999

    modifyArray(&numbers)
    fmt.Println(numbers[0])  // prints 999
}
Copy after login
Copy after login
Copy after login

let's add some elements to our slice

type slice struct {
    array unsafe.Pointer // Points to the actual data
    len   int           // Current number of elements
    cap   int           // Total available space
}
Copy after login
Copy after login
Copy after login

Since we have capacity (5) > length (3), Go:

Uses existing backing array
Places 10 at index 3
Increases length by 1

// Old growth pattern
capacity = oldCapacity * 2  // Simple doubling
Copy after login
Copy after login

Let's hit the limit

// New growth pattern
if capacity < 256 {
    capacity = capacity * 2
} else {
    capacity = capacity + capacity/4  // 25% growth
}
Copy after login
Copy after login

Oops! Now we have hit our capacity, we need to grow. Here is what happens:

  1. Calculates new capacity (oldCap < 256, so doubles to 10)
  2. Allocates new backing array (a new memory address say, 300)
  3. Copies existing elements to new backing array
  4. Adds new element
  5. Updates slice header

Arrays vs Slices in Go: Understanding the

func main() {
    // Zero-value initialization
    var nums [3]int    // Creates [0,0,0]

    // Fixed size
    nums[4] = 1       // Runtime panic: index out of range

    // Sized during compilation
    size := 5
    var dynamic [size]int  // Won't compile: non-constant array bound
}
Copy after login
Copy after login
Copy after login

what happens if it's a large slice?

func main() {
    // Different types!
    var a [5]int
    var b [6]int

    // This won't compile
    a = b // compile error: cannot use b (type [6]int) as type [5]int

    // But this works
    var c [5]int
    a = c // Same types, allowed
}
Copy after login
Copy after login
Copy after login

Since capacity is 256, Go uses the post-1.18 growth formula:

New capacity = oldCap oldCap/4
256 256/4 = 256 64 = 320

func modifyArrayCopy(arr [5]int) {
    arr[0] = 999    // Modifies the copy, not original
}

func modifyArray(arr *[5]int){
    arr[0] = 999  // Modifies the original, since reference is passed
}

func main() {
    numbers := [5]int{1, 2, 3, 4, 5}

    modifyArrayCopy(numbers)
    fmt.Println(numbers[0])  // prints 1, not 999

    modifyArray(&numbers)
    fmt.Println(numbers[0])  // prints 999
}
Copy after login
Copy after login
Copy after login

Why reference semantics?

  1. Performance: Copying large data structures is expensive
  2. Memory efficiency: Avoiding unnecessary data duplication
  3. Enabling shared views of data: Multiple slices can reference the same backing array
type slice struct {
    array unsafe.Pointer // Points to the actual data
    len   int           // Current number of elements
    cap   int           // Total available space
}
Copy after login
Copy after login
Copy after login

this is how the slice headers will look:

// Old growth pattern
capacity = oldCapacity * 2  // Simple doubling
Copy after login
Copy after login

Usage patterns and cautions for slice

Accidental updates

since slice uses reference semantics, it doesn't create copies which might lead to accidental mutation to original slice if not being mindful.

// New growth pattern
if capacity < 256 {
    capacity = capacity * 2
} else {
    capacity = capacity + capacity/4  // 25% growth
}
Copy after login
Copy after login

Expensive append operation

numbers := make([]int, 3, 5) // length=3 capacity

// Memory Layout after creation:
Slice Header:
{
    array: 0xc0000b2000    // Example memory address
    len:   3
    cap:   5
}

Backing Array at 0xc0000b2000:
[0|0|0|unused|unused]
Copy after login

Copy vs Append

numbers = append(numbers, 10)
Copy after login

Arrays vs Slices in Go: Understanding the

Let's wrap this up with a clear choice guide:

? Choose Arrays When:

  1. You know the exact size upfront
  2. Working with small, fixed data (like coordinates, RGB values)
  3. Performance is critical and data fits on stack
  4. You want type safety with size

? Choose Slices When:

  1. Size might change
  2. Working with dynamic data
  3. Need multiple views of same data
  4. Processing streams/collections

? Check out notion-to-md project! It's a tool that converts Notion pages to Markdown, perfect for content creators and developers. Join our discord community.

The above is the detailed content of Arrays vs Slices in Go: Understanding the 'under the hood' functioning visually. For more information, please follow other related articles on the PHP Chinese website!

source:dev.to
Statement of this Website
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn
Latest Articles by Author
Popular Tutorials
More>
Latest Downloads
More>
Web Effects
Website Source Code
Website Materials
Front End Template