String & Rune
String в Go — это immutable последовательность байтов (не символов!). Под капотом строка хранит UTF-8 encoded text, но сама структура ничего не знает о кодировке.
Memory Layout
Stack Heap / rodata
┌─────────────────┐
│ string header │ ┌─────────────────────┐
│ ┌─────────────┐ │ │ byte sequence │
│ │ ptr ────────┼─┼───────────▶│ [0xD0][0x9F][0xD1]..│ UTF-8 bytes
│ │ len = 12 │ │ └─────────────────────┘
│ └─────────────┘ │
└─────────────────┘
16 bytes// Внутренняя структура (reflect.StringHeader, deprecated)
// Реальная структура в runtime/string.go
type stringStruct struct {
str unsafe.Pointer // указатель на байты
len int // длина в БАЙТАХ
}len возвращает байты, не символы
s := "Привет"
len(s) // 12 (байт), не 6 (символов)
len([]rune(s)) // 6 (рун/символов)UTF-8 Encoding
Go использует UTF-8 — переменная длина от 1 до 4 байт на символ:
| Диапазон Unicode | Байт | Пример |
|---|---|---|
| U+0000..U+007F | 1 | ASCII: A = 0x41 |
| U+0080..U+07FF | 2 | Кириллица: П = 0xD0 0x9F |
| U+0800..U+FFFF | 3 | Китайский: 中 = 0xE4 0xB8 0xAD |
| U+10000..U+10FFFF | 4 | Emoji: 😀 = 0xF0 0x9F 0x98 0x80 |
s := "Привет"
fmt.Printf("% x\n", []byte(s))
// d0 9f d1 80 d0 b8 d0 b2 d0 b5 d1 82
// Каждая кириллическая буква = 2 байта
// П = d0 9f, р = d1 80, и = d0 b8, в = d0 b2, е = d0 b5, т = d1 82Индексация: byte vs rune
s := "Привет"
// Индексация возвращает БАЙТ, не руну
s[0] // 0xD0 (первый байт буквы "П")
s[1] // 0x9F (второй байт буквы "П")
// Чтобы получить символ, нужна конвертация
runes := []rune(s)
runes[0] // 'П' (1055 в Unicode)Итерация: range декодирует UTF-8
for i, c := range "Привет" {
fmt.Printf("i=%d c=%c\n", i, c)
}
// i=0 c=П
// i=2 c=р ← i увеличилось на 2 (размер предыдущей руны)
// i=4 c=и
// i=6 c=в
// i=8 c=е
// i=10 c=тКлючевой момент: i — это byte offset, не индекс символа.
Строка "Привет" в памяти:
Байт: 0 1 2 3 4 5 6 7 8 9 10 11
[П ][р ][и ][в ][е ][т ]
↑ ↑ ↑ ↑ ↑ ↑
i: 0 2 4 6 8 10
c: П р и в е тЕсли нужен индекс символа
// Вариант 1: отдельный счётчик
runeIdx := 0
for _, c := range "Привет" {
fmt.Printf("rune %d: %c\n", runeIdx, c)
runeIdx++
}
// Вариант 2: конвертация в []rune
runes := []rune("Привет")
for i, c := range runes {
fmt.Printf("rune %d: %c\n", i, c) // i — индекс символа
}Когда конвертировать в []rune
- Нужен random access по индексу символа
- Частые операции
len()для подсчёта символов - Модификация отдельных символов
Но помни: []rune создаёт новый slice и копирует все данные.
String Immutability
Строки неизменяемы. Любая "модификация" создаёт новую строку:
s := "hello"
s[0] = 'H' // ❌ не компилируется
// Нужно создать новую строку
s = "H" + s[1:] // аллокация новой строкиПочему immutable:
- Thread-safe — можно безопасно передавать между горутинами
- Hash stability — строки можно использовать как ключи map
- Compiler optimizations — string literals в read-only памяти
String Concatenation
Проблема: O(n²) при наивной конкатенации
// ❌ Avoid: новая аллокация на КАЖДОЙ итерации
s := ""
for _, part := range parts {
s += part // O(n) копирование каждый раз
}
// Итого: O(n²) по времени и памятиИтерация 1: "" + "abc" → alloc "abc" (3 bytes)
Итерация 2: "abc" + "def" → alloc "abcdef" (6 bytes, копируем 3+3)
Итерация 3: "abcdef" + "ghi" → alloc "abcdefghi" (9 bytes, копируем 6+3)
...Решение: strings.Builder
// ✅ O(n) — amortized append
var b strings.Builder
b.Grow(totalLen) // опционально, но рекомендуется
for _, part := range parts {
b.WriteString(part)
}
s := b.String()strings.Builder internals
// strings/builder.go
type Builder struct {
addr *Builder // для детекта копирования
buf []byte // растущий буфер
}
func (b *Builder) WriteString(s string) (int, error) {
b.buf = append(b.buf, s...)
return len(s), nil
}
func (b *Builder) String() string {
// unsafe конвертация без копирования!
return unsafe.String(unsafe.SliceData(b.buf), len(b.buf))
}Zero-copy String()
Builder.String() не копирует данные — возвращает string, который указывает на тот же буфер. Поэтому после String() нельзя продолжать писать в Builder.
Когда какой метод
| Метод | Когда использовать | Аллокации |
|---|---|---|
+ | 2-3 строки, известны на compile time | 1 |
fmt.Sprintf | Форматирование с разными типами | 1+ |
strings.Join | Slice строк с разделителем | 1 |
strings.Builder | Цикл, неизвестное число строк | 1 (с Grow) |
// Простая конкатенация — OK
full := first + " " + last // компилятор оптимизирует
// Slice с разделителем
path := strings.Join(parts, "/")
// Форматирование
msg := fmt.Sprintf("User %s (id=%d)", name, id)
// Цикл — только Builder
var b strings.Builder
for _, p := range parts {
b.WriteString(p)
}Zero-copy конвертации
string → []byte (обычно копирует)
s := "hello"
b := []byte(s) // аллокация + копирование 5 байтКогда НЕ копирует (compiler optimization)
// 1. Сравнение (Go 1.16+)
if string(byteSlice) == "expected" {
// string создаётся без аллокации
}
// 2. Map lookup (Go 1.16+)
m := map[string]int{"key": 42}
_ = m[string(byteSlice)] // без аллокации
// 3. Range over []byte as string
for _, c := range string(byteSlice) {
// без аллокации
}unsafe конвертация (осторожно!)
import "unsafe"
// string → []byte без копирования
// ⚠️ DANGER: нельзя модифицировать результат!
func unsafeBytes(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}
// []byte → string без копирования
// ⚠️ DANGER: нельзя модифицировать исходный slice!
func unsafeString(b []byte) string {
return unsafe.String(unsafe.SliceData(b), len(b))
}Когда unsafe конвертация ломается
b := []byte("hello")
s := unsafeString(b) // s указывает на b
b[0] = 'H' // модифицируем b
fmt.Println(s) // "Hello" — строка "изменилась"!
// Это нарушает контракт immutability
// и может сломать map, compiler optimizations, etc.Подсчёт символов
s := "Hello, 世界! 🎉"
len(s) // 20 (байт)
len([]rune(s)) // 13 (рун) — аллокация!
utf8.RuneCountInString(s) // 13 (рун) — без аллокации ✅import "unicode/utf8"
// Проверка валидности UTF-8
utf8.ValidString(s) // true если валидный UTF-8
// Декодирование первой руны
r, size := utf8.DecodeRuneInString(s)
// r = 'H', size = 1
// Для кириллицы
r, size := utf8.DecodeRuneInString("Привет")
// r = 'П', size = 2String interning
String interning — техника оптимизации памяти, при которой одинаковые строки хранятся в единственном экземпляре. Вместо создания дубликатов, все ссылки указывают на один объект в специальном пуле (intern pool).
Без interning: С interning:
┌─────────┐ ┌─────────┐
│ var a ──┼──▶ "hello" (0x100) │ var a ──┼──┐
└─────────┘ └─────────┘ │
┌─────────┐ ┌─────────┐ ▼
│ var b ──┼──▶ "hello" (0x200) │ var b ──┼──▶ "hello" (intern pool)
└─────────┘ └─────────┘ ▲
┌─────────┐ ┌─────────┐ │
│ var c ──┼──▶ "hello" (0x300) │ var c ──┼──┘
└─────────┘ └─────────┘
3 аллокации 1 аллокацияGo не делает автоматический interning (в отличие от Java, где строковые литералы и результаты String.intern() автоматически попадают в пул). Каждый string([]byte{...}) создаёт новую строку:
a := string([]byte{'h', 'e', 'l', 'l', 'o'})
b := string([]byte{'h', 'e', 'l', 'l', 'o'})
// a и b — разные аллокации, хотя содержимое одинаковоеНо string literals с одинаковым содержимым разделяют память:
a := "hello"
b := "hello"
// a и b указывают на одни и те же байты в rodataЧастые ошибки
Substring создаёт alias
Substring в Go не копирует данные — он создаёт новый stringHeader, указывающий на тот же backing array со смещённым указателем:
s := "very long string that takes lots of memory"
prefix := s[:4] // "very"Исходная строка s:
┌──────────────────┐
│ stringHeader │
│ ┌──────────────┐ │ Backing array (44 bytes)
│ │ Data: 0x100 ─┼─┼─────▶┌─────────────────────────────────────────────┐
│ │ Len: 44 │ │ │ v e r y l o n g s t r i n g t h a ... │
│ └──────────────┘ │ └─────────────────────────────────────────────┘
└──────────────────┘ ▲
│
Substring prefix: │ (тот же адрес!)
┌──────────────────┐ │
│ stringHeader │ │
│ ┌──────────────┐ │ │
│ │ Data: 0x100 ─┼─┼────────┘
│ │ Len: 4 │ │ ← только длина изменилась
│ └──────────────┘ │
└──────────────────┘Проблема: пока prefix жив, GC не может освободить весь 44-байтный backing array — даже если оригинальная строка s уже не используется.
Реальный сценарий утечки памяти
// ❌ Memory leak: читаем файлы, храним только имена
var fileNames []string
for _, path := range hugeDirListing {
// Каждая строка path может быть 200+ символов:
// "/very/long/path/to/some/deeply/nested/directory/file.txt"
content, _ := os.ReadFile(path)
line := string(content) // Например, 10KB на файл
// Берём только первые 50 символов как "превью"
preview := line[:50]
fileNames = append(fileNames, preview)
// preview держит ссылку на ВСЕ 10KB каждого файла!
}
// При 1000 файлов: ожидаем ~50KB, реально ~10MB в памятиОжидание: Реальность:
┌─────────────────┐ ┌─────────────────┐
│ fileNames[0] ───┼─▶ 50 bytes │ fileNames[0] ───┼─▶ 10KB (весь файл!)
│ fileNames[1] ───┼─▶ 50 bytes │ fileNames[1] ───┼─▶ 10KB
│ fileNames[2] ───┼─▶ 50 bytes │ fileNames[2] ───┼─▶ 10KB
│ ... │ │ ... │
└─────────────────┘ └─────────────────┘
~50KB total ~10MB totalРешения
1. strings.Clone (Go 1.20+) — рекомендуемый способ:
preview := strings.Clone(line[:50])После Clone:
┌──────────────────┐
│ preview │ Новый backing array (50 bytes)
│ ┌──────────────┐ │ ┌──────────────────────────────────────────────────┐
│ │ Data: 0x500 ─┼─┼─────▶│ v e r y l o n g s t r i n g t h a t t ...│
│ │ Len: 50 │ │ └──────────────────────────────────────────────────┘
│ └──────────────┘ │
└──────────────────┘
Оригинальные 10KB теперь могут быть освобождены GC ✓2. Конвертация через []byte (до Go 1.20):
preview := string([]byte(line[:50]))3. strings.Builder для множественных операций:
var b strings.Builder
b.WriteString(line[:50])
preview := b.String() // новая аллокацияКогда alias — это хорошо
Не всегда нужно копировать. Если substring короткоживущий или оригинал тоже нужен — alias экономит память и CPU:
func hasPrefix(s, prefix string) bool {
if len(s) < len(prefix) {
return false
}
return s[:len(prefix)] == prefix // alias OK — временный
}Как обнаружить проблему
// Проверить, разделяют ли строки память
func sharesBackingArray(a, b string) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
aStart := uintptr(unsafe.Pointer(unsafe.StringData(a)))
aEnd := aStart + uintptr(len(a))
bStart := uintptr(unsafe.Pointer(unsafe.StringData(b)))
bEnd := bStart + uintptr(len(b))
return aStart < bEnd && bStart < aEnd // диапазоны пересекаются
}В pprof: ищи аллокации строк, которые не соответствуют ожидаемому размеру.
Invalid UTF-8
// Невалидный UTF-8
bad := string([]byte{0xff, 0xfe})
for _, r := range bad {
fmt.Printf("%U ", r) // U+FFFD U+FFFD (replacement character)
}Go заменяет невалидные последовательности на \uFFFD (replacement character).
Файлы runtime/
| Файл | Назначение |
|---|---|
runtime/string.go | String operations, concatenation |
runtime/utf8.go | UTF-8 encoding/decoding |
strings/builder.go | strings.Builder implementation |
unicode/utf8/utf8.go | Public UTF-8 API |
Debugging
# Найти string аллокации
go build -gcflags="-m" 2>&1 | grep "string"
# Бенчмарк конкатенации
go test -bench=. -benchmem ./...// Проверить sharing backing storage
import "reflect"
func sharesMemory(a, b string) bool {
ha := (*reflect.StringHeader)(unsafe.Pointer(&a))
hb := (*reflect.StringHeader)(unsafe.Pointer(&b))
return ha.Data == hb.Data
}Дальнейшее чтение
- Strings, bytes, runes and characters in Go — официальный блог
- UTF-8 and Unicode FAQ — исчерпывающее руководство
- Исходники:
$GOROOT/src/runtime/string.go,unicode/utf8/utf8.go