День 1: Slices, Strings и модель памяти
🎯 Темы дня
- Slices: growth strategy, nil vs empty, 3-index slice
- Strings: rune vs byte, UTF-8, zero-copy конверсии
- 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 после выполнения?
gos := 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
govar 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)→nulljson.Marshal(emptySlice)→[]- Для API это критичная разница!
3-index slice (Go 1.2+)
goa := []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
gos := [][]int{{1}, {2}, {3}} for _, sub := range s { sub = append(sub, 99) // ❌ НЕ меняет оригинал! } // s остался [[1], [2], [3]]
Почему: sub — копия заголовка слайса. append может создать новый underlying array, но оригинал об этом не узнает.
Правильно:
gofor i := range s { s[i] = append(s[i], 99) // ✅ }
📖 2. Strings — не просто байты
Строка — это read-only слайс байт
gotype StringHeader struct { Data uintptr Len int }
Строка иммутабельна. Любая "модификация" создает новую строку.
Rune vs Byte
gos := "Привет" fmt.Println(len(s)) // 12 байт (UTF-8, каждая буква по 2 байта) fmt.Println(len([]rune(s))) // 6 рун (символов)
Правило: Если работаешь с текстом на русском/китайском/эмодзи — используй []rune, а не []byte.
⚠️ Ловушка: индексация строки
gos := "Go — это круто" fmt.Println(s[0]) // 71 (байт 'G') fmt.Println(s[3]) // 226 (первый байт '—', а не сам символ!)
Индексация по байтам, не по символам!
Zero-copy конверсии (unsafe)
goimport "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
Компилятор решает, где аллоцировать переменную:
bashgo build -gcflags="-m" main.go
Что уходит в heap:
- Возврат указателя на локальную переменную
- Интерфейсные значения (динамический тип)
- Замыкания, захватывающие переменные
- Большие структуры (> размер стека горутины, ~2KB начальный)
- Каналы, map'ы, слайсы (их underlying data)
Пример анализа
gofunc 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:
gofunc 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
Проверь разницу в выводе:
govar 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" на этом коде и объясни, что куда попадет:
gotype 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 }
🗣️ Каверзные вопросы
-
Что выведет?
goa := [3]int{1, 2, 3} s := a[:] s = append(s, 4) fmt.Println(a)Ответ:
[1 2 3]— массив имеет фиксированный размер,appendсоздаст новый underlying array. -
Чем
var s []intотличается отs := []int{}? Ответ: Первый — nil slice, второй — empty slice. JSON-сериализация разная. -
Почему
strings.Builderбыстрее конкатенации в цикле? Ответ: Использует внутренний буфер с амортизированным ростом, избегая O(N²) копирований. -
Где будет аллоцирован
map? Ответ: Всегда в heap (даже если создается локально), потому что это ссылочный тип с динамическим размером.
🧩 Проверь себя
✅ Чек-лист дня
- Могу объяснить growth strategy слайсов
- Понимаю разницу nil vs empty slice и её последствия для JSON
- Знаю, что строка — это read-only байты, а не руны
- Могу запустить escape analysis и объяснить его вывод
- Понимаю, почему map всегда в heap