7. strings. Работа со строками

Природа строки в Go: неизменяемость и внутреннее устройство

«Строки — фундаментальная абстракция: они неизменяемы, их легко передавать, но они требуют уважительного и чуткого отношения к байтам, скрывающимся под капотом».
— Брайан Керниган

В языке C строка — это просто массив байтов, завершающийся нулевым символом (\0). Это порождало бесконечные ошибки переполнения буфера (Buffer Overflow) и мучительное отслеживание того, кто и когда владеет выделенной памятью. В Go проектировщики подошли к строкам иначе: строка (string) — это неизменяемый (immutable) срез байтов с фиксированной длиной.

Эта фундаментальная особенность диктует все правила работы с текстом, стратегии оптимизации системной памяти и безопасные подходы к конкурентности в горутинах.

Когда вы объявляете строковую переменную s := "Hello", рантайм компилятора Go оперирует компактной 16-байтовой структурой (на 64-битной архитектуре) runtime.stringStruct:

Go
type stringStruct struct {
    str unsafe.Pointer // Указатель на данные (байты)
    len int            // Длина строки в байтах
}
DIAGRAM
Заголовок строки stringStruct в оперативной памяти (16 байт):
+-----------------------------+-----------------------------+
| str (unsafe.Pointer, 8 байт) |       len (int, 8 байт)     |
+-----------------------------+-----------------------------+
               │
               ▼
+-----------------------------------+
| 'H' | 'e' | 'l' | 'l' | 'o' | ... | (Read-only сегмент .rodata или куча)
+-----------------------------------+

Сравните это со срезом (slice), заголовок которого занимает 24 байта (Data, Len, Cap). У строки нет поля Cap (вместимость), потому что она принципиально не может расти или модифицироваться на месте (in-place).


Механика аллокаций: ловушка конвертации []byte ↔ string

Самая частая причина деградации производительности в Go-приложениях, работающих с текстом (парсинг логов, обработка JSON, разбор HTTP-заголовков), — это скрытые аллокации при конвертации между слайсом байтов и строкой.

Почему конвертация дорогая?

Операция string(b) или []byte(s) требует выделения новой памяти в куче и копирования содержимого.

  1. Рантайм не может гарантировать, что слайс []byte не будет изменен после создания строки.
  2. Строка должна оставаться строго неизменной (контракт иммутабельности).
  3. Следовательно, рантайм обязан создать независимую копию данных.
Go
func process(data []byte) {
    // ❌ ПЛОХО: Аллокация + копирование на каждой итерации
    s := string(data) 
    if strings.HasPrefix(s, "GET") { ... }
}

func processOptimized(data []byte) {
    // ✅ ХОРОШО: Работа напрямую с байтами, ноль аллокаций
    if bytes.HasPrefix(data, []byte("GET")) { ... }
}

Оптимизация сборки строк: от конкатенации до strings.Builder

В Go существует четыре основных способа собрать строку из нескольких фрагментов. Их производительность различается на порядки.

1. Конкатенация оператором +

Go
s := "Hello" + " " + "World"
  • Компилятор: Оптимизирует конкатенацию известных на этапе компиляции констант.
  • Рантайм: Для переменных создает новую строку на каждую операцию +. Если вы делаете это в цикле, сложность становится $O(N^2)$ из-за постоянного выделения памяти и копирования растущего объема данных.
  • Вердикт: Допустимо для 2-3 частей. Запрещено в циклах.

2. fmt.Sprintf

Go
s := fmt.Sprintf("%s %d", name, id)

3. bytes.Buffer

Go
var buf bytes.Buffer
buf.WriteString("Hello")
s := buf.String()
  • Механизм: Работает с []byte. При вызове .String() делает копию данных (так как не может отдать внутренний буфер, который может расти).
  • Вердикт: Хорошо, если вам нужен результат в виде []byte. Для строк есть решение лучше.

4. strings.Builder (Золотой стандарт)

Go
var b strings.Builder
b.Grow(32) // Подсказка компилятору/рантайму
b.WriteString("Hello")
s := b.String()
  • Механизм: Внутри используется срез []byte, но метод .String() реализован без аллокаций. Пакет strings скрывает внутренний буфер и возвращает строку через unsafe.String без копирования данных. При этом strings.Builder динамически контролирует неизменность собственного адреса (copyCheck), предотвращая небезопасное копирование билдера по значению.
  • Метод Grow(n int): Критически важен. Он предварительно выделяет память (make([]byte, 0, n)). Без него Builder будет вынужден делать каскадные реаллокации (удвоение емкости) по мере роста, что приводит к фрагментации кучи и лишним копированиям.
flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    A["strings.Builder"]:::process --> B["Внутренний []byte buf"]
    B --> C{"Вызов .String()"}
    C -->|"До Go 1.10"| D["Копирование данных:<br>make + copy"]
    C -->|"Go 1.10+"| E["Unsafe cast:<br>zero-copy conversion"]
    E --> F["Строка указывает<br>на тот же адрес памяти"]
  

Поиск и манипуляции: strings vs bytes

Пакет strings предоставляет функции Contains, Index, Replace, Split и другие. Важно помнить: почти каждая функция в strings имеет полный функциональный аналог в пакете bytes.

Правило инженера: Если ваши входные данные уже находятся в формате []byte (например, вы читаете из io.Reader или получаете тело HTTP-запроса), никогда не конвертируйте их в string ради использования функций из пакета strings. Используйте пакет bytes.

Go
// ❌ Плохо: 2 аллокации (конвертация в строку + результат Split)
parts := strings.Split(string(rawData), ",")

// ✅ Хорошо: 1 аллокация (только результат Split, данные не копируются при поиске разделителя)
parts := bytes.Split(rawData, []byte(","))

Функции поиска в bytes работают напрямую с памятью, используя оптимизированные алгоритмы (часто ассемблерные реализации с SIMD/AVX для длинных паттернов), и избегают накладных расходов на создание временных строк.


Unicode и UTF-8: мифы о длине строки

Строка в Go — это произвольный набор байтов, который по соглашению оформлен в кодировке UTF-8. Это означает, что функция len(s) возвращает количество байтов, а не символов (рун).

Go
s := "Привет"
fmt.Println(len(s))                   // 12 (6 букв * 2 байта в UTF-8 для кириллицы)
fmt.Println(utf8.RuneCountInString(s)) // 6

Итерация по строке через ключевое слово range автоматически декодирует UTF-8 поток на лету:

Go
for i, r := range s {
    // i - индекс начала руны в байтах
    // r - значение руны (int32)
}

Сравнение строк

Оператор == для строк в Go сравнивает сначала длину (len), а если длины равны — побайтово содержимое.

  • Для коротких строк это очень быстро (буквально одна процессорная инструкция сравнения регистров).
  • Для длинных строк компилятор может использовать векторизованные оптимизации, но в худшем случае это $O(N)$.

Если вам нужно часто сравнивать большие строки (например, ключи в распределенном кэше), рассмотрите возможность предварительного вычисления и хранения их хешей (например, uint64 через быстрые некриптографические хеш-функции fnv или xxhash) и сравнения хешей. Однако будьте осторожны с возможными коллизиями.


Итог

  1. Строка — это неизменяемый срез байтов. Присваивание дешево (копируется дескриптор из 16 байт), конвертация []byte <-> string дорога (копируются данные).
  2. Избегайте конвертации в циклах. Используйте пакет bytes для работы с []byte.
  3. Для сборки строк используйте strings.Builder. Всегда вызывайте Grow() для предварительного выделения памяти, чтобы избежать каскадных реаллокаций.
  4. Помните про UTF-8. len() считает байты. Для подсчета символов используйте utf8.RuneCountInString. Индексация s[i] работает строго по байтам.
  5. Будьте осторожны с unsafe. Трюки с нулевой конвертацией возможны, но нарушают безопасность типов и требуют глубокого понимания модели памяти Go.

Разобравшись с высокоуровневыми строками, мы должны спуститься на уровень ниже, к их сырому представлению. В следующей статье мы изучим пакет, который дает полный контроль над памятью и байтами: 8. bytes. Работа с []byte и буферами.