Модуль: Core Go · Уровень: Senior

TL;DR#

string в Go — это неизменяемая (immutable) пара «указатель + длина» (StringHeader{ptr, len}), указывающая на read-only последовательность байт, без какого-либо требования к кодировке. Текст в исходниках и стандартной библиотеке хранится в UTF-8, поэтому len(s) возвращает количество байт, s[i] — отдельный байт, а for i, r := range s декодирует UTF-8 и отдаёт руны (rune = int32, Unicode code point) вместе с байтовым смещением. Конвертации string<->[]byte и string<->[]rune в общем случае копируют память (аллокация в куче), но компилятор умеет несколько важных оптимизаций (ключ map, range []byte(s)), а strings.Builder и unsafe.String/Slice позволяют избегать лишних копий — каждая со своими опасностями.

Теория#

string под капотом: StringHeader#

Строка на уровне рантайма представлена структурой из двух машинных слов:

// reflect.StringHeader (концептуально)
type stringStruct struct {
    str unsafe.Pointer // указатель на массив байт
    len int            // длина в БАЙТАХ
}

На 64-битной платформе unsafe.Sizeof("") == 16 (8 байт указатель + 8 байт длина). Важные следствия:

  • Строка — это value type из двух слов. Передача строки в функцию копирует header (16 байт), а не сам массив байт. Сам массив разделяется (shared).
  • Immutability гарантируется компилятором и рантаймом. Память, на которую указывает строка, помечена как read-only (строковые литералы кладутся в read-only сегмент бинарника). Попытка записи через unsafe — UB, часто segfault.
  • Нет требования к кодировке. Строка — это просто байты. UTF-8 — это лишь соглашение, которому следуют литералы, range, fmt и пакет unicode/utf8. В строке могут лежать произвольные байты, в т.ч. невалидный UTF-8.
s := "héllo"
fmt.Println(len(s))                    // 6 (é занимает 2 байта в UTF-8)
fmt.Println(utf8.RuneCountInString(s)) // 5

Отличие от []byte#

string[]byte
Структура{ptr, len} (2 слова){ptr, len, cap} (3 слова, slice header)
Изменяемостьimmutablemutable
Памятькак правило read-onlyread-write куча/стек
Сравнение ==да (поэлементно)нет (только bytes.Equal)
Ключ mapданет
Zero value""nil

[]byte — это slice header из трёх слов, потому что у него есть cap для роста через append. У строки роста нет, поэтому cap не нужен.

rune и UTF-8#

rune — это псевдоним int32, представляющий Unicode code point (значение от 0 до 0x10FFFF). Это НЕ «символ» в бытовом смысле: один видимый глиф (grapheme cluster) может состоять из нескольких code points (например, emoji с модификаторами или базовая буква + комбинируемый диакритик).

UTF-8 кодирует code point в 1–4 байта:

Code pointБайтШаблон
U+0000–U+007F (ASCII)10xxxxxxx
U+0080–U+07FF2110xxxxx 10xxxxxx
U+0800–U+FFFF31110xxxx 10xxxxxx 10xxxxxx
U+10000–U+10FFFF411110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Свойства UTF-8, которые важно знать:

  • ASCII (0–127) кодируется одним байтом, идентичным самому коду — обратная совместимость.
  • Ведущий байт многобайтовой последовательности всегда отличается от continuation-байтов (10xxxxxx), поэтому можно безопасно «прыгать» по строке вперёд/назад и находить границы рун.
  • UTF-8 self-synchronizing: потеряв позицию, можно восстановить её, пропуская continuation-байты.
  • Невалидный байт декодируется в utf8.RuneError (U+FFFD, «replacement character»), RuneError == '�'.

Итерация: range даёт руны, индексация — байты#

Ключевое senior-различие:

s := "aé文"
// range декодирует UTF-8: i — байтовое смещение начала руны, r — руна
for i, r := range s {
    fmt.Printf("byte=%d rune=%c (U+%04X)\n", i, r, r)
}
// byte=0 rune=a (U+0061)
// byte=1 rune=é (U+00E9)   <- следующий i прыгнул на 3, т.к. é = 2 байта
// byte=3 rune=文 (U+6587)

// индексация даёт ОДИН байт, тип byte (uint8)
fmt.Println(s[1]) // 195 — первый байт двухбайтовой é, НЕ 'é'

Под капотом range по строке вызывает декодер UTF-8 (аналог utf8.DecodeRuneInString) на каждой итерации. При встрече невалидного байта возвращает RuneError и сдвигается на 1 байт. Это означает: длина цикла range по строке = число рун, а не байт; обычный for i := 0; i < len(s); i++ обходит байты.

len(s)                    // байты
utf8.RuneCountInString(s) // руны (быстрее, чем []rune(s), т.к. без аллокации)

Конвертации и их стоимость#

b := []byte(s)   // копирует len(s) байт в новый слайс (аллокация в куче)
s2 := string(b)  // копирует len(b) байт в новую строку (аллокация)
r := []rune(s)   // декодирует ВСЮ строку, аллоцирует []int32 (4 байта на руну)
s3 := string(r)  // кодирует каждую руну в UTF-8 (аллокация)

Почему копирование обязательно в общем случае:

  • string immutable, []byte mutable. Если бы string(b) делил память с b, изменение b нарушило бы immutability строки. Поэтому — копия.
  • []rune(s) принципиально другой layout (4 байта на code point вместо 1–4 байт UTF-8), декодирование неизбежно.

Стоимость: O(n) по времени и памяти. Для горячего пути это значимо. []rune особенно дорог: для ASCII он раздувает память в 4 раза.

Оптимизации компилятора (без аллокации)#

Компилятор Go распознаёт несколько идиом и убирает копию/аллокацию:

  1. string([]byte) как ключ map — поиск/чтение без аллокации:
m[string(b)]++          // не аллоцирует временную строку
v, ok := m[string(b)]   // тоже

Компилятор знает, что временная строка живёт только во время lookup и не «утекает», поэтому делает поиск прямо по байтам.

  1. range []byte(s) — итерация по байтам строки без аллокации слайса:
for i, c := range []byte(s) { ... } // []byte(s) не аллоцируется
  1. Сравнение string(b) == "literal" — без аллокации строки из b.

  2. switch string(b) / if string(b) == ... в ряде случаев.

Эти оптимизации завязаны на escape-анализ: временная строка не должна «убегать». Проверить можно через go build -gcflags='-m'.

strings.Builder#

strings.Builder амортизирует аллокации при склейке строк, накапливая байты во внутреннем []byte и отдавая итог через unsafe-конверсию без финальной копии.

type Builder struct {
    addr *Builder // of receiver, для copyCheck
    buf  []byte
}

func (b *Builder) String() string {
    return *(*string)(unsafe.Pointer(&b.buf)) // НЕ копирует buf
}

Ключевые моменты:

  • Без копии в String(). buf интерпретируется как строка через unsafe. Это безопасно, потому что Builder инвариантом не отдаёт наружу мутабельный доступ к уже «застроканному» буферу (после String() буфер логически не должен меняться так, чтобы порушить выданную строку; на практике дальнейший WriteString может вызвать append с новым массивом).
  • copyCheck: Builder нельзя копировать. Поле addr хранит адрес самого Builder при первом использовании. Если структуру скопировали (например, передали по значению), b.addr != bcopyCheck паникует:
func (b *Builder) copyCheck() {
    if b.addr == nil {
        b.addr = (*Builder)(noescape(unsafe.Pointer(b)))
    } else if b.addr != b {
        panic("strings: illegal use of non-zero Builder copied by value")
    }
}

Причина: после копирования две копии делили бы один buf; unsafe-выданные строки могли бы стать невалидными при росте одной из копий. go vet (copylocks-подобная проверка) и рантайм-паника защищают от этого.

  • Grow(n) заранее резервирует ёмкость, минимизируя реаллокации.
  • Реализует io.Writer, WriteByte, WriteRune, WriteString.

unsafe-конвертации без копирования (Go 1.20+)#

До Go 1.20 использовали хаки через reflect.StringHeader/SliceHeader, которые формально некорректны (GC мог не «увидеть» указатель). Начиная с Go 1.20 есть официальные функции:

// []byte -> string без копии: строка ссылается на тот же массив
s := unsafe.String(unsafe.SliceData(b), len(b))

// string -> []byte без копии
b := unsafe.Slice(unsafe.StringData(s), len(s))

Опасности (почему это unsafe):

  • Нарушение immutability. unsafe.Slice(StringData(s), len(s)) даёт мутабельный доступ к памяти строки. Запись в него — UB; для строковых литералов это запись в read-only память → segfault.
  • Lifetime / GC. Нужно гарантировать, что исходный объект жив, пока используется результат. unsafe.StringData/SliceData дают сырой указатель.
  • cap у результата unsafe.Slice равен переданной длине, но запись всё равно запрещена для строк.
  • Применять только когда вы владеете байтами и гарантируете, что после конвертации []byte больше не мутируется (классический паттерн: построили буфер, отдали как строку, дальше его не трогаем).

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

  • ==, != — поэлементное (побайтовое) сравнение. Сначала рантайм сравнивает длины и указатели (если ptr+len совпадают — равны мгновенно), иначе memequal.
  • <, >, <=, >= — лексикографическое сравнение по байтам (фактически по UTF-8), не по code points в смысле локали и не с учётом регистра. Для валидного UTF-8 побайтовое сравнение совпадает с порядком по code points (свойство кодировки).
  • strings.Compare существует, но ==/< обычно эффективнее и идиоматичнее; Compare нужен для трёхзначного результата.
  • Сравнение НЕ нормализует Unicode: «é» как один code point (U+00E9) и «e»+комбинируемый акут (U+0065 U+0301) — разные строки. Для нормализации нужен golang.org/x/text/unicode/norm.

Подводные камни / gotchas#

  • s[i] — это byte, а не символ. Для не-ASCII вернёт середину UTF-8 последовательности.
s := "é"
fmt.Println(s[0])      // 195
fmt.Printf("%c\n", s[0]) // Ã — мусор
  • len(s) ≠ число символов. Для подсчёта рун — utf8.RuneCountInString(s).
  • «Реверс строки» через байты ломает UTF-8. Реверсить нужно руны: []rune(s), развернуть, обратно string. И даже это сломает grapheme clusters (emoji с модификаторами, комбинируемые диакритики).
  • string(int) — это НЕ форматирование числа, а code point. string(65) == "A". С Go 1.15+ go vet ругается на string(int); для чисел используйте strconv.Itoa/fmt.Sprint. string(rune(65)) — корректный явный способ.
  • Конкатенация в цикле s += ... — O(n²) по памяти/времени (каждый += аллоцирует новую строку). Используйте strings.Builder.
  • Подстрока через slicing делит память с оригиналом. big[0:10] ссылается на тот же массив, что и big (огромная строка) — большой буфер не освобождается GC, пока жива подстрока. Лечится копией: string([]byte(big[0:10])).
  • []rune(s) для ASCII тратит ×4 памяти — не используйте, если достаточно байт/рун-итерации.
  • Копирование strings.Builder по значению → паника при следующей записи (copyCheck). Передавайте только по указателю.
  • Невалидный UTF-8 в range молча превращается в U+FFFD и сдвиг на 1 байт — можно не заметить порчу данных.
  • unsafe.String/Slice + последующая мутация байт = UB, часто незаметный (данные «портятся» уже выданной строки) или segfault на литералах.

Вопросы на собеседовании#

В: Что такое string в Go на уровне рантайма и сколько памяти занимает переменная типа string? О: Это структура из двух машинных слов: указатель на массив байт и длина в байтах ({ptr, len}). На 64-бит — 16 байт (unsafe.Sizeof("") == 16). Сам массив байт хранится отдельно (для литералов — в read-only сегменте) и может разделяться между строками. Передача строки копирует только header, не данные.

В: Почему len("привет") возвращает 12, а не 6? О: len для строки возвращает число байт, а не рун. Текст хранится в UTF-8; кириллица лежит в диапазоне U+0400–U+04FF, который кодируется двумя байтами на символ, поэтому 6 букв × 2 = 12 байт. Для подсчёта рун — utf8.RuneCountInString.

В: Чем отличается for i, c := range s от for i := 0; i < len(s); i++? О: range по строке декодирует UTF-8: c имеет тип rune (code point), i — байтовое смещение начала руны (шаг по строке неравномерный — 1–4 байта). Обычный цикл по индексу обходит байты: s[i] имеет тип byte. На невалидном UTF-8 range отдаёт RuneError (U+FFFD) и сдвигается на 1 байт.

В: Почему конвертация []byte(s) копирует данные? Можно ли без копии? О: Потому что string immutable, а []byte mutable; разделяя память, можно было бы изменить строку через слайс, что нарушило бы инварианты языка и GC/оптимизаций. Поэтому в общем случае копия обязательна (O(n), аллокация). Без копии — только через unsafe.Slice(unsafe.StringData(s), len(s)) (Go 1.20+), но тогда вы обязаны не мутировать результат (для литералов это segfault) и следить за lifetime.

В: Какие конвертации string[]byte компилятор оптимизирует до zero-alloc? О: Несколько идиом, опирающихся на escape-анализ: m[string(b)] (доступ к map по ключу), string(b) == "literal" (сравнение), range []byte(s) (итерация), switch string(b). Во всех случаях временная строка/слайс не «убегает» из выражения, поэтому копия не делается. Проверяется через -gcflags=-m.

В: Как strings.Builder избегает финальной аллокации в String() и почему его нельзя копировать? О: Внутри он накапливает байты в []byte buf, а в String() интерпретирует этот буфер как строку через unsafe.Pointer без копирования. Нельзя копировать, потому что после копии две структуры разделяли бы один buf; рост одной (через append с реаллокацией) или мутация порушили бы уже выданные unsafe-строки и инварианты. Защита — copyCheck: при первом использовании Builder запоминает свой адрес в поле addr; если addr != &b, значит копировали по значению → паника.

В: string(65) — что вернёт и почему это потенциальная ошибка? О: Вернёт "A", потому что конвертация целого в строку трактует число как Unicode code point, а не форматирует его в десятичную запись. Частая ошибка новичков, ожидающих "65". go vet предупреждает об этом начиная с Go 1.15. Для числа в текст — strconv.Itoa; для явного code point — string(rune(65)).

В: Как лексикографически сравниваются строки и совпадает ли это с порядком Unicode code points? О: </> сравнивают строки побайтово (по UTF-8 представлению). Для валидного UTF-8 это совпадает с порядком по code points — свойство кодировки (UTF-8 сохраняет порядок code points в байтовом сравнении). == сначала сравнивает длины (и при совпадении ptr — мгновенно равны), иначе memequal. Сравнение не нормализует Unicode и не учитывает регистр/локаль.

В: Почему s += part в цикле — плохо, и что произойдёт с памятью? О: Строки immutable, поэтому каждый += создаёт новую строку, копируя весь уже накопленный результат — это O(n²) копирований и множество мусорных аллокаций. Решение — strings.Builder (или bytes.Buffer), который накапливает в растущем буфере с амортизированной O(1) вставкой, плюс Grow для предаллокации.

На что копают на senior+#

  • Escape-анализ и zero-alloc идиомы. Senior должен не просто знать про оптимизацию m[string(b)], а уметь объяснить, что она держится на escape-анализе, и показать через -gcflags=-m, когда она ломается (например, если временную строку сохранить в переменную — копия появится).
  • Memory pinning через подстроки. Follow-up: «У вас в кэше лежат короткие подстроки от гигабайтного ответа, память не освобождается — почему?» Ответ: slicing строки делит backing array; короткая подстрока удерживает весь буфер. Лечение — strings.Clone (Go 1.18+) или копия через []byte.
  • Grapheme clusters vs runes vs code points. «Реверс emoji» или «обрезать строку до N символов» — ловушка: руна ≠ видимый символ. Senior упомянет нормализацию (NFC/NFD) и grapheme clusters (x/text), что обычный кандидат игнорирует.
  • Корректность unsafe.String/Slice. Спросят про lifetime, read-only память литералов, почему старый хак через reflect.SliceHeader некорректен с точки зрения GC (нет указателя, который GC отслеживает; объект может быть собран). Знание перехода на unsafe.StringData/SliceData в 1.20 — маркер актуальности.
  • Внутренности strings.Builder. Follow-up про copyCheck, noescape, почему String() безопасен несмотря на unsafe, и чем Builder отличается от bytes.Buffer (Builder — write-only, оптимизирован под итоговую строку; Buffer — read-write, реализует io.Reader).
  • UTF-8 self-synchronization. Могут спросить, почему можно искать границы рун в обе стороны без полного парсинга с начала — ответ про различимость ведущих и continuation-байтов.
  • Сравнение производительности RuneCountInString vs len([]rune(s)) — первое не аллоцирует, второе аллоцирует весь слайс рун; senior выберет первое и объяснит почему.