День 1: Slices, Strings и модель памяти

🎯 Темы дня

  1. Slices: growth strategy, nil vs empty, 3-index slice
  2. Strings: rune vs byte, UTF-8, zero-copy конверсии
  3. Memory: stack vs heap, escape analysis, GC основы

📖 1. Slices — глубокое погружение

Growth strategy

При append, если cap не хватает, Go увеличивает capacity по правилу:

  • cap < 256 → удвоение (newcap = 2 * oldcap)
  • cap >= 256 → рост на 25% + 192 (newcap = 1.25 * oldcap + 192)

Это сделано для плавного перехода от агрессивного роста к экономному.

Вопрос: Какой будет cap после выполнения?

go
s := make([]int, 0)
for i := 0; i < 5; i++ {
    s = append(s, i)
}
fmt.Println(cap(s))

Ответ: 8. Рост: 0 → 1 → 2 → 4 → 8 (удвоение на каждом шаге до 5 элементов).

Nil slice vs Empty slice

go
var nilSlice []int        // nil, len=0, cap=0
emptySlice := []int{}     // не nil, len=0, cap=0
emptySlice2 := make([]int, 0) // не nil, len=0, cap=0

fmt.Println(nilSlice == nil)    // true
fmt.Println(emptySlice == nil)  // false

Почему это важно:

  • json.Marshal(nilSlice) → null
  • json.Marshal(emptySlice) → []
  • Для API это критичная разница!

3-index slice (Go 1.2+)

go
a := []int{1, 2, 3, 4, 5}
b := a[1:3:4] // [start:low:high]
// b = [2, 3], cap(b) = 4-1 = 3

Зачем: ограничить capacity, чтобы append(b, ...) не затронул оригинальный массив.

⚠️ Ловушка: append в цикле с range

go
s := [][]int{{1}, {2}, {3}}
for _, sub := range s {
    sub = append(sub, 99) // ❌ НЕ меняет оригинал!
}
// s остался [[1], [2], [3]]

Почему: sub — копия заголовка слайса. append может создать новый underlying array, но оригинал об этом не узнает.

Правильно:

go
for i := range s {
    s[i] = append(s[i], 99) // ✅
}

📖 2. Strings — не просто байты

Строка — это read-only слайс байт

go
type StringHeader struct {
    Data uintptr
    Len  int
}

Строка иммутабельна. Любая "модификация" создает новую строку.

Rune vs Byte

go
s := "Привет"
fmt.Println(len(s))        // 12 байт (UTF-8, каждая буква по 2 байта)
fmt.Println(len([]rune(s))) // 6 рун (символов)

Правило: Если работаешь с текстом на русском/китайском/эмодзи — используй []rune, а не []byte.

⚠️ Ловушка: индексация строки

go
s := "Go — это круто"
fmt.Println(s[0])   // 71 (байт 'G')
fmt.Println(s[3])   // 226 (первый байт '—', а не сам символ!)

Индексация по байтам, не по символам!

Zero-copy конверсии (unsafe)

go
import "unsafe"

func stringToBytes(s string) []byte {
    return unsafe.Slice(unsafe.StringData(s), len(s))
}

func bytesToString(b []byte) string {
    return unsafe.String(unsafe.SliceData(b), len(b))
}

Опасно: строка иммутабельна, а слайс — нет. Если изменить слайс — будет UB.


📖 3. Memory: Stack vs Heap

Escape analysis

Компилятор решает, где аллоцировать переменную:

bash
go build -gcflags="-m" main.go

Что уходит в heap:

  1. Возврат указателя на локальную переменную
  2. Интерфейсные значения (динамический тип)
  3. Замыкания, захватывающие переменные
  4. Большие структуры (> размер стека горутины, ~2KB начальный)
  5. Каналы, map'ы, слайсы (их underlying data)

Пример анализа

go
func foo() *int {
    x := 42
    return &x  // "moved to heap: x"
}

func bar() int {
    x := 42
    return x   // остается в stack
}

Stack горутины

  • Начальный размер: 2KB
  • Растет динамически (до GB)
  • При росте — копируется содержимое (поэтому указатели на стек опасны)

GC (Garbage Collector)

  • Non-generational, concurrent, tri-color mark-and-sweep
  • Цель: latency < 500μs (а не throughput)
  • Не собирает: указатели из стека, глобальные переменные, финализаторы

Практический вывод: Не создавай много мелких объектов в hot path — это нагружает GC. Используй sync.Pool для переиспользования.


💻 Практика (1–2 часа)

Задание 1: Проверь growth strategy

Напиши бенчмарк, который замеряет cap слайса после каждого append:

go
func BenchmarkSliceGrowth(b *testing.B) {
    s := make([]int, 0)
    for i := 0; i < b.N; i++ {
        s = append(s, i)
        if i%100 == 0 {
            b.Logf("len=%d cap=%d", len(s), cap(s))
        }
    }
}

Задание 2: JSON marshaling

Проверь разницу в выводе:

go
var nilSlice []int
emptySlice := make([]int, 0)

b1, _ := json.Marshal(nilSlice)
b2, _ := json.Marshal(emptySlice)

fmt.Println(string(b1)) // null
fmt.Println(string(b2)) // []

Задание 3: Escape analysis

Запусти go build -gcflags="-m" на этом коде и объясни, что куда попадет:

go
type User struct {
    Name string
    Age  int
}

func createUser() *User {
    u := User{Name: "Alice", Age: 30}
    return &u
}

func processString(s string) {
    b := []byte(s)
    _ = b
}

🗣️ Каверзные вопросы

  1. Что выведет?

    go
    a := [3]int{1, 2, 3}
    s := a[:]
    s = append(s, 4)
    fmt.Println(a)

    Ответ: [1 2 3] — массив имеет фиксированный размер, append создаст новый underlying array.

  2. Чем var s []int отличается от s := []int{}? Ответ: Первый — nil slice, второй — empty slice. JSON-сериализация разная.

  3. Почему strings.Builder быстрее конкатенации в цикле? Ответ: Использует внутренний буфер с амортизированным ростом, избегая O(N²) копирований.

  4. Где будет аллоцирован map? Ответ: Всегда в heap (даже если создается локально), потому что это ссылочный тип с динамическим размером.


🧩 Проверь себя

1. Какой будет cap(s) после s := make([]int, 0, 10); s = append(s, 1, 2, 3)?
2. Что выведет json.Marshal для nil-слайса var s []int?
3. Почему map всегда аллоцируется в heap?
4. Почему len("Привет") = 12, а len([]rune("Привет")) = 6?
5. Что выведет этот код? a := [3]int{1, 2, 3}; s := a[:]; s = append(s, 4); fmt.Println(a)
6. Как устроена growth strategy слайса при cap >= 256?
7. Чем b := a[1:3:4] отличается от b := a[1:3]? (третий индекс — cap)

✅ Чек-лист дня

  • Могу объяснить growth strategy слайсов
  • Понимаю разницу nil vs empty slice и её последствия для JSON
  • Знаю, что строка — это read-only байты, а не руны
  • Могу запустить escape analysis и объяснить его вывод
  • Понимаю, почему map всегда в heap