Skip to content

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
go
// Внутренняя структура (reflect.StringHeader, deprecated)
// Реальная структура в runtime/string.go
type stringStruct struct {
    str unsafe.Pointer  // указатель на байты
    len int             // длина в БАЙТАХ
}

len возвращает байты, не символы

go
s := "Привет"
len(s)           // 12 (байт), не 6 (символов)
len([]rune(s))   // 6 (рун/символов)

UTF-8 Encoding

Go использует UTF-8 — переменная длина от 1 до 4 байт на символ:

Диапазон UnicodeБайтПример
U+0000..U+007F1ASCII: A = 0x41
U+0080..U+07FF2Кириллица: П = 0xD0 0x9F
U+0800..U+FFFF3Китайский: = 0xE4 0xB8 0xAD
U+10000..U+10FFFF4Emoji: 😀 = 0xF0 0x9F 0x98 0x80
go
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

go
s := "Привет"

// Индексация возвращает БАЙТ, не руну
s[0]  // 0xD0 (первый байт буквы "П")
s[1]  // 0x9F (второй байт буквы "П")

// Чтобы получить символ, нужна конвертация
runes := []rune(s)
runes[0]  // 'П' (1055 в Unicode)

Итерация: range декодирует UTF-8

go
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:      П     р     и     в     е     т

Если нужен индекс символа

go
// Вариант 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

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

go
s := "hello"
s[0] = 'H'  // ❌ не компилируется

// Нужно создать новую строку
s = "H" + s[1:]  // аллокация новой строки

Почему immutable:

  1. Thread-safe — можно безопасно передавать между горутинами
  2. Hash stability — строки можно использовать как ключи map
  3. Compiler optimizations — string literals в read-only памяти

String Concatenation

Проблема: O(n²) при наивной конкатенации

go
// ❌ 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

go
// ✅ O(n) — amortized append
var b strings.Builder
b.Grow(totalLen)  // опционально, но рекомендуется
for _, part := range parts {
    b.WriteString(part)
}
s := b.String()

strings.Builder internals

go
// 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 time1
fmt.SprintfФорматирование с разными типами1+
strings.JoinSlice строк с разделителем1
strings.BuilderЦикл, неизвестное число строк1 (с Grow)
go
// Простая конкатенация — 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 (обычно копирует)

go
s := "hello"
b := []byte(s)  // аллокация + копирование 5 байт

Когда НЕ копирует (compiler optimization)

go
// 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 конвертация (осторожно!)

go
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 конвертация ломается

go
b := []byte("hello")
s := unsafeString(b)  // s указывает на b

b[0] = 'H'  // модифицируем b
fmt.Println(s)  // "Hello" — строка "изменилась"!

// Это нарушает контракт immutability
// и может сломать map, compiler optimizations, etc.

Подсчёт символов

go
s := "Hello, 世界! 🎉"

len(s)                    // 20 (байт)
len([]rune(s))            // 13 (рун) — аллокация!
utf8.RuneCountInString(s) // 13 (рун) — без аллокации ✅
go
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 = 2

String 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{...}) создаёт новую строку:

go
a := string([]byte{'h', 'e', 'l', 'l', 'o'})
b := string([]byte{'h', 'e', 'l', 'l', 'o'})
// a и b — разные аллокации, хотя содержимое одинаковое

Но string literals с одинаковым содержимым разделяют память:

go
a := "hello"
b := "hello"
// a и b указывают на одни и те же байты в rodata

Частые ошибки

Substring создаёт alias

Substring в Go не копирует данные — он создаёт новый stringHeader, указывающий на тот же backing array со смещённым указателем:

go
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 уже не используется.

Реальный сценарий утечки памяти

go
// ❌ 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+) — рекомендуемый способ:

go
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):

go
preview := string([]byte(line[:50]))

3. strings.Builder для множественных операций:

go
var b strings.Builder
b.WriteString(line[:50])
preview := b.String()  // новая аллокация

Когда alias — это хорошо

Не всегда нужно копировать. Если substring короткоживущий или оригинал тоже нужен — alias экономит память и CPU:

go
func hasPrefix(s, prefix string) bool {
    if len(s) < len(prefix) {
        return false
    }
    return s[:len(prefix)] == prefix  // alias OK — временный
}

Как обнаружить проблему

go
// Проверить, разделяют ли строки память
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

go
// Невалидный 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.goString operations, concatenation
runtime/utf8.goUTF-8 encoding/decoding
strings/builder.gostrings.Builder implementation
unicode/utf8/utf8.goPublic UTF-8 API

Debugging

bash
# Найти string аллокации
go build -gcflags="-m" 2>&1 | grep "string"

# Бенчмарк конкатенации
go test -bench=. -benchmem ./...
go
// Проверить 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
}

Дальнейшее чтение

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