JavaRush /Courses /Go SELF /Underlying array sharing

Underlying array sharing

Go SELF
Level 12, Lesson 2
Available

1. Why analyze a shared underlying array

Imagine you make a neat “piece” of a slice: you took the first 3 elements, changed something, and then suddenly discovered that it “changed somewhere else too.” At first glance it looks like a bug; at third glance, like mysticism; and at fifth, like a pattern.

Today we will break down that pattern: sub-slices usually share the same backing/underlying array, so changes made through one slice are visible through another. This model is not a mistake in Go, but a design choice: it provides speed and memory savings, but requires attentiveness.

Mental model: a slice is a “window”

If you previously thought of a slice as a “mini-array,” you are not alone: it is a very common illusion (roughly like “Git is a USB drive for code”). But in Go, a slice is a small descriptor structure: where the data lives, how much is “visible,” and how much can be “visible when it grows.”

In a simplified form, a slice stores pointer + length + capacity , and two slices can look at the same array.

Let’s draw this as a diagram. Suppose we have one underlying array, and two slices look at it through different “windows”:

underlying array (data):
indexes:  0   1   2   3   4
         [A] [B] [C] [D] [E]

s := s[0:5]      sees A B C D E
t := s[1:4]      sees   B C D

Important: the data (A..E) is physically the same, while the “windows” (s and t) are different.

2. Sub-slices using a task list example

To avoid playing with abstract A B C, let’s continue our educational console application: a mini task manager. For now, no structs and no fancy words—just a list of strings.

We want to display “today’s tasks” as the first N tasks from the full list. And this is where the first trap usually appears: we take a sub-slice, assume it is a “copy,” modify it—and get surprised.

Creating a task list and a “view of the first two”

First, minimal code that simply slices a slice:

package main

import "fmt"

func main() {
	tasks := []string{"buy bread", "read a chapter", "go for a walk"}
	today := tasks[:2]

	fmt.Println("tasks:", tasks) // tasks: [buy bread read a chapter go for a walk]
	fmt.Println("today:", today) // today: [buy bread read a chapter]
}

So far everything looks harmless: today is “the first two tasks.”

Modify today—and tasks unexpectedly changes

Now let’s add a single line that “marks a task as done” (roughly, just by adding a prefix):

package main

import "fmt"

func main() {
	tasks := []string{"buy bread", "read a chapter", "go for a walk"}
	today := tasks[:2]

	today[0] = "[x] " + today[0]

	fmt.Println("today:", today) // today: [[x] buy bread read a chapter]
	fmt.Println("tasks:", tasks) // tasks: [[x] buy bread read a chapter go for a walk]
}

Why does this happen? Because today[0] and tasks[0] are the same cell in the underlying array; you can just reach it through different “windows.”

4. How the connection shows up: writes, functions, two windows

In this section we will reinforce the “shared underlying array” idea in three ways: via addresses, via passing into a function, and via two different sub-slices.

Verify the connection manually: identical element addresses

Now there is a small trick that helps your brain believe what’s going on. We already encountered &x when reading input via fmt.Scan, as the idea of “where to store the value.” Here we use & in the same spirit: “where the element is located.”

We will print the address of the first element in tasks and the address of the first element in today. If the underlying array is shared and the window starts at the same place, the addresses will match.

package main

import "fmt"

func main() {
	tasks := []string{"buy bread", "read a chapter", "go for a walk"}
	today := tasks[:2]

	fmt.Printf("%p\n", &tasks[0]) // for example: 0xc0000a4000
	fmt.Printf("%p\n", &today[0]) // for example: 0xc0000a4000
}

The numbers will be different on your run—that’s normal. The key observation: they match. That is “shared memory” in practice.

Aliasing through functions: “passed it to a function—and it changed the list”

Beginners often expect that “if I passed something to a function, then it’s a copy.” In Go that is sometimes true (for example, arrays [N]T are copied), but with slices the situation is trickier: the function receives a copy of the slice header, while the underlying array remains shared.

That’s exactly why changing elements inside the function is visible outside: we are not changing a “local copy of the data,” but shared data.

Let’s make a function markDoneFirst that marks the first task as done:

package main

import "fmt"

func markDoneFirst(tasks []string) {
	if len(tasks) == 0 {
		return
	}
	tasks[0] = "[x] " + tasks[0]
}

func main() {
	all := []string{"buy bread", "read a chapter"}
	markDoneFirst(all)

	fmt.Println(all) // [[x] buy bread read a chapter]
}

There’s no “magic” here—this is a direct consequence of shared memory.

Two sub-slices and the “leaking” changes effect

Sometimes the problem shows up not between sub-slice ↔ original, but between two sub-slices. Both look at one underlying array, and if their ranges overlap (or even if they just live in the same array), changes can be unexpected.

Imagine we split tasks into “morning” and “evening” as two windows:

package main

import "fmt"

func main() {
	tasks := []string{"exercise", "work", "walk", "sleep"}

	morning := tasks[:2] // exercise, work
	evening := tasks[2:] // walk, sleep

	evening[0] = "[x] " + evening[0]

	fmt.Println("tasks:  ", tasks)    // tasks:   [exercise work [x] walk sleep]
	fmt.Println("morning:", morning)  // morning: [exercise work]
	fmt.Println("evening:", evening)  // evening: [[x] walk sleep]
}

Here morning did not change because we modified the element tasks[2], and morning only looks at tasks[0] and tasks[1]. But the important fact remains: changes go into the shared underlying array; they are just not always visible to every window (it all depends on the range).

5. Append and cap: how a sub-slice can corrupt neighbors

Now we’re getting to the scenario that most often causes real pain. It sounds like this: “I took the first 2 elements, did append, and suddenly the third element of the original slice changed.”

Again, this is not mysticism. If the sub-slice has remaining cap, then append may write data into the same underlying array—that is, right next to what you thought were “someone else’s data.” Official slice materials constantly emphasize that different slices can share one array, and operations that change the array contents affect all observers.

Let’s show it with tasks:

package main

import "fmt"

func main() {
	tasks := []string{"A", "B", "C", "D"}

	head := tasks[:2]        // len=2, cap usually 4
	head = append(head, "X") // append the third element

	fmt.Println("head: ", head)  // head:  [A B X]
	fmt.Println("tasks:", tasks) // tasks: [A B X D]
}

Why did C turn into X? Because head “saw” spare capacity and append decided: “Why allocate a new array if I can write into this one?” And it wrote into the cell tasks[2].

What matters here is not memorizing “magic append behavior,” but understanding the rule: if growth fits within cap, we append into the same array.

6. When sharing is useful and when it’s dangerous

Honestly, underlying sharing is one reason Go is often fast “out of the box.” Instead of copying big chunks of memory on every s[a:b], the language performs a cheap operation: it creates a new slice header that points to the same memory. It’s fast and efficient: you can carve out “windows” for data processing almost for free.

But the flip side is responsibility. If you hand a sub-slice to the outside (for example, return it from a function) and someone modifies it, they modify shared data. If inside a function you take a sub-slice and do append, you might accidentally overwrite the tail of the original array. This is not “bad Go,” it is “Go honestly optimizing what you asked for.”

To lock it in, here is a small table of how to think about operations:

Action What usually happens Why it matters
sub := s[a:b]
A new “window” is created; data is not copied sub and s are linked
sub[i] = ...
An element of the underlying array is modified Changes are visible to all slices that “see” it
sub = append(sub, x)
Writing goes into the same array (if there is enough cap) You can overwrite “neighboring” elements of the original slice
sub = append(sub, x)
A new array is allocated and elements are copied (if there is not enough cap) The link may “break,” but relying on that as a contract is risky

Mini story: a “task showcase” and unexpected changes

Let’s assemble a realistic situation. Suppose we want to show a “showcase” of the first tasks (for example, the first 3), and then separately work with the full list.

Naively, you might do this: preview := tasks[:3], and then “tweak the format” in the preview (for example, add numbers). But that will modify the original list.

Here is a small example that demonstrates the problem:

package main

import "fmt"

func main() {
	tasks := []string{"buy bread", "read a chapter", "go for a walk"}
	preview := tasks[:2]

	preview[1] = "2) " + preview[1]

	fmt.Println("preview:", preview) // preview: [buy bread 2) read a chapter]
	fmt.Println("tasks:  ", tasks)   // tasks:   [buy bread 2) read a chapter go for a walk]
}

If your “showcase” should be presentation-only and not a data modification, then such a write is a logical bug: we accidentally mixed “how it is stored” with “how it is displayed.”

This is where a useful design rule is usually born: if a function or piece of code should only read, try not to write into the slice at all (and especially not into sub-slices).

7. Common mistakes

Mistake #1: thinking that sub := s[a:b] creates a copy of the elements.
This is a very natural mistake: visually, sub looks like a separate list. But in reality it is just another window into the same data. If you write into sub[i], you write into the underlying array, and that can be reflected in s. Therefore, in places where you need a “snapshot of the data,” slicing alone is not enough.

Mistake #2: assuming that t := s is copying.
Assigning a slice copies only the header. As a result, t and s look at the same underlying array. This mistake is especially nasty in code that, “for neatness,” does tmp := original and then “safely” modifies tmp. The safety is only in the variable name.

Mistake #3: modifying a sub-slice in a function that, by meaning, should only read.
Often the bug is not in Go and not in slices, but in the function contract. If a function is named something like printTasks, but inside it suddenly does tasks[0] = ..., that is almost always a surprise to the caller. Even if it “technically works,” later it turns into very hard-to-trace problems.

Mistake #4: unexpected side effects due to append into a sub-slice.
When a sub-slice has spare cap, append can write into the same array and overwrite elements “next to it.” This looks like “memory broke” or “Go acts weird sometimes,” but in reality it is a predictable consequence of shared capacity. If you see that after append the original slice data changed, almost always the reason is exactly this.

Mistake #5: trying to “fix” the problem with debug printing without understanding the model.
It is very tempting to add fmt.Println in ten places and catch where it “broke for the first time.” Printing helps, but without the underlying sharing model you will treat symptoms: add extra copying where it is not needed, or conversely leave a dangerous section. It is much more reliable to fix one thing in your head once: sub-slices are connected because the data is shared, and a slice is only a window into that data.

1
Task
Go SELF, level 12, lesson 2
Locked
Editing window
Editing window
1
Task
Go SELF, level 12, lesson 2
Locked
Two aliases
Two aliases
1
Task
Go SELF, level 12, lesson 2
Locked
Adjacent slot
Adjacent slot
1
Task
Go SELF, level 12, lesson 2
Locked
Preview without mutation
Preview without mutation
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION