JavaRush /Courses /Go SELF /Common bugs: aliasing

Common bugs: aliasing

Go SELF
Level 12, Lesson 5
Available

1. Where “magic” slice bugs come from

When you are just starting to program, you want to believe a simple fairy tale: “If I made a new slice, that means it’s a new list.” And in some languages that’s true. But Go is a language that saves you copies (and does it quite successfully), so behind the scenes it often turns out you did not create a “new list”, but rather a “new window onto old data”.

A slice stores pointer+len+cap (this is the official model that is convenient to think in). Because of this, two slices can have different len, but the same backing array. And append is a whole separate character: it may create a new array, and it may append into the old one. And that “may” depends on cap.

This is exactly where the genre classic is born: “I appended an element to a sub-slice, and my data in the original slice got corrupted.” Let’s break down what this looks like in practice—using a small learning app as an example.

Mini ToDo app: slicing a task list

So that bugs with aliasing are not abstract but real, imagine we are writing a tiny console planner. For now, without structs and files: just a list of tasks []string. This matches our current course level: we already have strings and slices, and we will leave structs alone for now.

Let’s start with a utility that prints a slice together with len/cap, so that we can see the “hidden capacity”:

package main

import "fmt"

func dump(name string, s []string) {
	fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}

Comment: dump is our “X-ray”. It won’t show memory directly, but it will show the main thing: whether the slice has spare cap into which append can theoretically write.

2. Bug #1: append into a sub-slice overwrites the original slice

Now we will reproduce the most famous bug of the day: we take “tasks for today” as a sub-slice, do an append, and then see that “tasks for tomorrow” suddenly changed.

package main

import "fmt"

func dump(name string, s []string) {
	fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}

func main() {
	tasks := []string{"read", "walk", "do the dishes", "sleep"}
	today := tasks[:2] // "window" onto the first two tasks

	dump("tasks", tasks)
	dump("today", today)

	today = append(today, "urgent: buy bread")

	dump("tasks", tasks)
	dump("today", today)
}

One typical output may look like this (your specific cap numbers may differ, but the effect matters more):

tasks: len=4 cap=4 [read walk do the dishes sleep]
today: len=2 cap=4 [read walk]
tasks: len=4 cap=4 [read walk urgent: buy bread sleep]
today: len=3 cap=4 [read walk urgent: buy bread]

We appended an element to today, but tasks[2] changed. Why? Because today had cap=4, meaning it had access to the available “tail” in the backing array, and append decided: “why allocate a new array if I can write right here”.

You can imagine it roughly like this:

Backing array (tasks):
[read] [walk] [do the dishes] [sleep]
   ^       ^
   |       |
 today window: [0..2) len=2, but cap lets it reach the end of the array

After append(today, "..."), the third element of the backing array was overwritten, and the original tasks saw it.

4. Bug #2: “sometimes it works” because of cap

The most annoying part of this bug is that it does not always show up. And that makes it an ideal candidate for “Friday” bugs: it worked on Thursday, crashed on Friday, and works again on Monday because you restarted the program and capacities were distributed a bit differently.

Here it is important to understand a simple rule:

If the slice has enough cap, append may append into the current backing array. If it does not have enough—then it is forced to allocate a new backing array and copy the elements there.

Let’s make an example where we explicitly control cap to see two different behaviors.

Case A: small cap → append allocates a new array

package main

import "fmt"

func dump(name string, s []string) {
	fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}

func main() {
	tasks := make([]string, 2, 2)
	tasks[0] = "read"
	tasks[1] = "walk"

	today := tasks[:] // len=2 cap=2

	today = append(today, "urgent: buy bread")

	dump("tasks", tasks) // tasks: [read walk]
	dump("today", today) // today: [read walk urgent: buy bread]
}

Here cap(today)==2, there is no space, so append creates a new backing array for today. The original tasks does not change.

Case B: large cap → append writes into the shared array

package main

import "fmt"

func dump(name string, s []string) {
	fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}

func main() {
	tasks := make([]string, 4, 4)
	tasks[0] = "read"
	tasks[1] = "walk"
	tasks[2] = "do the dishes"
	tasks[3] = "sleep"

	today := tasks[:2] // len=2 cap=4

	today = append(today, "urgent: buy bread")

	dump("tasks", tasks) // tasks[2] overwritten
	dump("today", today)
}

That is why the bug is “floating”: it depends on cap, and cap often depends on how exactly you built the slice before that (via make, via append, via returning from a function, and so on).

5. Bug #3: a function returned a sub-slice—and it got “broken” from above

Now let’s create a situation that appears in real code more often than we’d like: you have a function that returns a sub-slice (for example, “tasks for today”), and somewhere higher in the code someone does append to it, assuming it is a separate list.

First, let’s write a “naive” function:

package main

func todayTasks(all []string) []string {
	if len(all) < 2 {
		return all
	}
	return all[:2] // sub-slice, shared backing array
}

And usage:

package main

import "fmt"

func todayTasks(all []string) []string {
	if len(all) < 2 {
		return all
	}
	return all[:2]
}

func main() {
	all := []string{"read", "walk", "do the dishes", "sleep"}
	today := todayTasks(all)

	today = append(today, "urgent: buy bread")

	fmt.Println(all)   // [read walk urgent: buy bread sleep]
	fmt.Println(today) // [read walk urgent: buy bread]
}

Technically, todayTasks did nothing “wrong”. It honestly returned a sub-slice. But from the product/app-logic perspective this can be a disaster: you wanted “add to today’s plan”, not “overwrite tomorrow’s task”.

And this is the key point to feel: aliasing is not a “language bug”. It is the normal slice model. The bug appears when the ownership contract is not stated in the code.

Why append is especially dangerous

In previous topics we already reinforced the rule “always store the result of append”. Today we add the second part, more devious: append not only changes the result’s len, it can also write into a shared backing array.

You can express it briefly like this:

append is safe as an operation, but dangerous as an intention if you are not sure who owns the buffer.

The situation becomes even nastier if you store “original” data somewhere, expecting it to remain unchanged:

original := tasks
today := tasks[:2]
today = append(today, "X")

// original and tasks are also "windows" onto the same backing array

Here original is not a “copy”, but the same kind of alias.

6. How to fix it: three strategies

Now comes the most practical part of the lecture: what to do so that append into a sub-slice does not surprise you.

I will give three strategies. They are not “mutually exclusive”, and the choice depends on what you actually want semantically.

A small decision table

What you want semantically What we do in code Why it works
“I return a view, but I forbid expanding it via append” limit cap via s[a:b:b] append will be forced to allocate a new array
“I return an independent list; it can be modified however” do make + copy a new backing array; no aliasing
“I return a sub-slice and honestly allow modifying the shared array” do nothing, but explicitly document the contract this is also a valid option, but it must be intentional

Next, we’ll go through the first two strategies with code.

7. Defense #1: limit cap on the sub-slice

This trick is like putting a “stopper” on a door: you can walk, but you cannot expand.

If you want to return “tasks for today” as a view, but you do not want someone to be able to use append to reach into the tail of the shared array, do this:

package main

func todayTasksSafe(all []string) []string {
	if len(all) < 2 {
		return all[:len(all):len(all)] // cap=len, even if len is less than 2
	}
	return all[:2:2] // len=2 cap=2
}

Pay attention to all[:2:2]: the third index is the “capacity boundary”. As a result, the returned slice’s cap becomes exactly len, and append will not be able to write into the tail of the shared array.

Let’s test it:

package main

import "fmt"

func todayTasksSafe(all []string) []string {
	if len(all) < 2 {
		return all[:len(all):len(all)]
	}
	return all[:2:2]
}

func main() {
	all := []string{"read", "walk", "do the dishes", "sleep"}
	today := todayTasksSafe(all)

	today = append(today, "urgent: buy bread")

	fmt.Println(all)   // [read walk do the dishes sleep]
	fmt.Println(today) // [read walk urgent: buy bread]
}

Here append will still work, but it will be forced to allocate a new backing array for today, because cap(today)==2.

A very important nuance: limiting cap does not make the data independent. If you do today[0] = "...", it will still change all[0], because the elements are shared. This trick protects specifically from “growing into the tail”.

8. Defense #2: make a copy via copy

If semantically you need a full copy (for example, “return a separate list of today’s tasks that will be edited further as a draft”), then the correct path is to create a new slice and copy the elements.

Let’s make a function “independent tasks for today”:

package main

func todayTasksCopy(all []string) []string {
	n := 2
	if len(all) < n {
		n = len(all)
	}

	src := all[:n]
	dst := make([]string, len(src))
	copy(dst, src)

	return dst
}

And test it:

package main

import "fmt"

func todayTasksCopy(all []string) []string {
	n := 2
	if len(all) < n {
		n = len(all)
	}
	src := all[:n]
	dst := make([]string, len(src))
	copy(dst, src)
	return dst
}

func main() {
	all := []string{"read", "walk", "do the dishes", "sleep"}
	today := todayTasksCopy(all)

	today[0] = "rewrote in the copy"
	today = append(today, "urgent: buy bread")

	fmt.Println(all)   // [read walk do the dishes sleep]
	fmt.Println(today) // [rewrote in the copy walk urgent: buy bread]
}

Now today is fully independent: you can change elements, you can expand it, you can do anything—the original all will not suffer.

Yes, it is copying, and it costs resources. But it is usually cheaper to copy once than to spend three days later hunting why “the third element in the task list suddenly changed” (and it changed because someone elsewhere did append).

9. How to diagnose aliasing on the spot

When you catch strange behavior, it helps to think like a detective: “where does this slice have extra cap?”

The simplest practical technique at our course level is to temporarily print len/cap for suspicious slices and check whether the sub-slice has spare capacity:

package main

import "fmt"

func dump(name string, s []string) {
	fmt.Printf("%s: len=%d cap=%d %v\n", name, len(s), cap(s), s)
}

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

	dump("all", all) // all: len=4 cap=4 ...
	dump("sub", sub) // sub: len=2 cap=4 ... <-- that “extra capacity”
}

If you see cap(sub) > len(sub), then append(sub, "...") is potentially able to write into the shared backing array. It will not always write (sometimes Go will decide differently, for example if append triggers growth bigger than the remaining cap), but the risk is already there.

And that is exactly the moment when you should either limit cap, or copy, or explicitly forbid append by contract.

10. Typical mistakes

Mistake #1: thinking that sub := s[a:b] creates a “new list”.
In practice, this is almost always just a new window onto the same data. If you then modify elements of the sub-slice, you modify the backing array, which means you potentially modify the original slice too. This is not a compiler bug and not “memory broke”; it is the normal slice model.

Mistake #2: doing append into a sub-slice and being surprised that s got corrupted.
If the sub-slice has cap > len, then append may write into the “tail” of the shared array. Externally, this looks like “I added an element in one place, but something changed somewhere else.” It is fixed either by limiting cap (s[a:b:b]), or by copying.

Mistake #3: hoping that “it seems not to break, so everything is fine”.
The most harmful bugs are the floating ones. Today append allocated a new array and everything is nice; tomorrow it appended into the shared buffer and you got a surprise. If logic requires independence, make it explicit via copy. If logic requires a “view without growth”, do a cap-limit explicitly.

Mistake #4: confusing “protection from append” with “full independence”.
all[:2:2] protects only from growth, but not from element changes. If you want a complete break of the link, you need make + copy. Beginners often do a cap-limit and are sure “now it’s a copy”, and then they change today[0] and are surprised that all[0] changed too.

Mistake #5: returning a sub-slice from a function without a stated contract.
When a function returns all[:2], the caller can easily decide it is a “separate result” and start doing append. As a result, the original list breaks, and it looks like “the function author” is at fault, even though formally they did something allowed. Good practice is either to return all[:2:2] (if it is a view), or to return a copy (if it is a result that will be modified).

1
Task
Go SELF, level 12, lesson 5
Locked
Shared buffer
Shared buffer
1
Task
Go SELF, level 12, lesson 5
Locked
New array
New array
1
Task
Go SELF, level 12, lesson 5
Locked
Safe preview
Safe preview
1
Task
Go SELF, level 12, lesson 5
Locked
Independent copy
Independent copy
1
Survey/quiz
Slices and aliasing, 12 level, 5 lesson
Unavailable
Slices and aliasing
Slices and aliasing
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION