Skip to content

Slice Append: Shared vs Separate Backing Arrays

Когда s1 и s2 указывают на одну память, а когда на разную?

Предварительные знания

Эта страница предполагает понимание структуры slice: header (ptr, len, cap) vs backing array.

Ключевая идея

  1. Slice header передаётся по значению — функция получает копию 24 байт
  2. Backing array не копируется — несколько headers могут указывать на один массив
  3. append возвращает новый header — но может писать в существующий backing array

Два сценария append

✅ Capacity достаточно — Shared Array
0/4
1s1 := make([]int, 2, 4)
2s1[0], s1[1] = 10, 20
3
4// Добавляем 1 элемент: 2+1=3 ≤ 4 (cap)
5s2 := append(s1, 30)
6
7fmt.Println(&s1[0] == &s2[0])
❌ Capacity превышен — New Array
0/4
1s1 := make([]int, 2, 4)
2s1[0], s1[1] = 10, 20
3
4// Добавляем 3 элемента: 2+3=5 > 4 (cap)
5s2 := append(s1, 30, 40, 50)
6
7fmt.Println(&s1[0] == &s2[0])

Реальный баг: неожиданная мутация

Функция получает слайс, добавляет элемент и возвращает новый слайс. Проблема: если capacity хватило, оба результата указывают на один массив и затирают друг друга.

append возвращает новый слайс, но может изменить чужие данные

s2 := append(s1, x)
  • s1 не изменится — его len и cap остаются прежними
  • Но если cap(s1) > len(s1) — элемент x запишется в общий backing array
  • Все слайсы, ссылающиеся на этот массив, увидят изменение

Почему 200 затирает 100?

original сохраняет len=2 после каждого append. Оба вызова видят len=2 → оба пишут в индекс [2] → второй затирает первого.

Slice Mutation Bug — пошаговое выполнение
0/11
1func addElement(items []int, elem int) []int {
2 return append(items, elem)
3}
4
5func main() {
6 original := make([]int, 2, 4)
7 original[0], original[1] = 1, 2
8
9 resultA := addElement(original, 100)
10 resultB := addElement(original, 200)
11
12 fmt.Printf("len: original=%d, resultA=%d, resultB=%d\n",
13 len(original), len(resultA), len(resultB))
14
15 fmt.Println("original:", original)
16 fmt.Println("resultA: ", resultA)
17 fmt.Println("resultB: ", resultB)
18
19 // Но данные в памяти УЖЕ изменены!
20 fmt.Println("original[:cap]:", original[:cap(original)])
21}

Best Practices: как защититься

1️⃣ Идиоматичный Go: всегда s = append(s, x)

90% случаев. Если работаешь с одной переменной — проблемы нет.

go
s := []int{1, 2}
s = append(s, 3)  // ✅ всегда присваиваем результат той же переменной

Баги появляются когда делают s2 := append(s1, x) и работают с обоими.

2️⃣ slices.Clone() — Go 1.21+ (рекомендуется)

Когда функция принимает слайс и должна вернуть модифицированную копию.

go
import "slices"

func addElement(items []int, elem int) []int {
    return append(slices.Clone(items), elem)  // ✅ всегда новый массив
}

INFO

slices.Clone() = append([]T(nil), items...) — создаёт копию с cap=len.

3️⃣ make + copy — до Go 1.21

go
func addElement(items []int, elem int) []int {
    result := make([]int, len(items), len(items)+1)
    copy(result, items)
    return append(result, elem)
}

4️⃣ Three-index slice — редко

Когда не хочешь копировать данные, но нужно гарантировать изоляцию.

go
func addElement(items []int, elem int) []int {
    return append(items[:len(items):len(items)], elem)
}

Синтаксис: slice[low:high:max]

  • low — начальный индекс
  • high — конечный индекс (определяет len)
  • max — граница capacity (определяет cap = max - low)

Когда какой подход?

СитуацияРешение
Локальная работа со слайсомs = append(s, x)
Функция возвращает "новый" слайсslices.Clone() + append
Передача в горутинуslices.Clone()
Критичная производительностьthree-index slice

Статический анализ: почему линтеры не спасут

Что проверяют популярные линтеры?

ИнструментЛовит этот баг?Почему
go vet❌ НетПроверяет только очевидные ошибки
staticcheck❌ НетНет правила для slice aliasing
golangci-lint❌ НетНи один из 100+ линтеров не детектирует
go test -race⚠️ ЧастичноТолько concurrent записи

Почему это сложно детектировать?

Slice aliasing требует inter-procedural data-flow analysis:

go
func processItems(items []int) []int {
    return append(items, 42)  // Опасно? Зависит от вызывающего кода
}

// Безопасно:
result := processItems([]int{1, 2}) // len=cap, append создаст новый массив

// Баг:
base := make([]int, 2, 10)
a := processItems(base)
b := processItems(base) // Затрёт a!

Линтер должен отслеживать:

  • Откуда пришёл слайс
  • Какой у него cap vs len
  • Кто ещё держит ссылку на backing array

Это NP-сложная задача для общего случая.

Race detector помогает частично

go
// go test -race ПОЙМАЕТ этот баг
go func() {
    resultA := addElement(original, 100)
    _ = resultA
}()
go func() {
    resultB := addElement(original, 200)
    _ = resultB
}()

Но не поймает последовательный вызов:

go
// go test -race НЕ ПОЙМАЕТ
resultA := addElement(original, 100)
resultB := addElement(original, 200)  // Тихо затирает resultA

Практический тест на aliasing

Добавьте в свои тесты проверку изоляции:

go
func TestSliceIsolation(t *testing.T) {
    original := make([]int, 2, 4)
    original[0], original[1] = 1, 2

    a := addElement(original, 100)
    b := addElement(original, 200)

    // Если функция корректна, a и b независимы
    if a[2] != 100 {
        t.Errorf("a[2] = %d, want 100 (slice aliasing bug)", a[2])
    }
    if b[2] != 200 {
        t.Errorf("b[2] = %d, want 200", b[2])
    }
}

Как это решают другие языки

Rust: borrow checker делает баг невозможным

В Rust нельзя иметь несколько mutable ссылок одновременно:

rust
fn main() {
    let mut v = vec![1, 2, 3];

    let a = &mut v;
    let b = &mut v;  // ❌ Ошибка компиляции!
    //  ^^^ cannot borrow `v` as mutable more than once

    a.push(4);
    b.push(5);
}

Компилятор гарантирует на этапе компиляции, что не будет aliasing проблем.

Java: ArrayList.subList() — похожая проблема

java
List<Integer> original = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
List<Integer> sub = original.subList(0, 3);

original.add(6);  // Модифицируем original
System.out.println(sub.get(0));  // ConcurrentModificationException!

Java хотя бы бросает исключение при concurrent modification. Go молча даёт неправильный результат.

Сравнительная таблица

АспектGoRustJavaC++
Защита от aliasing❌ Нет✅ Borrow checker⚠️ Runtime exception❌ Нет
Когда узнаём о багеRuntime (если повезёт)Compile timeRuntimeRuntime/никогда
Накладные расходыНулевыеНулевыеRuntime checksНулевые
ФилософияДоверяем разработчикуЕсли компилируется — безопасноFail-fastДоверяем разработчику

Философия Go

Go намеренно выбирает простоту над безопасностью:

"Go is a language for software engineers, not academics." — Rob Pike

Это означает:

  • Меньше магии компилятора — код делает то, что написано
  • Ответственность на разработчике — знай свои инструменты
  • Производительность важнее — нет runtime проверок на каждый append

Вывод

Go даёт мощные примитивы, но требует понимания их семантики. Используйте slices.Clone() когда нужна изоляция — это явное выражение намерения.

Итог: что именно делает append

ЧтоМутирует?Пояснение
Переменная-аргумент❌ Нетslice header передаётся по значению
Backing array (cap хватило)⚠️ ДаЗаписывает по индексу [len]
Backing array (cap не хватило)❌ НетСоздаёт новый массив
Возвращаемое значениеВсегда новый header

Ключевое правило: если функция принимает []T и вызывает append, она должна либо:

  1. Возвращать результат вызывающему (пусть он решает)
  2. Клонировать слайс перед модификацией (slices.Clone)
  3. Документировать, что мутирует входной слайс

Go Deep Dive — книга для Senior разработчиков