Модуль: 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) |
| Изменяемость | immutable | mutable |
| Память | как правило read-only | read-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) | 1 | 0xxxxxxx |
| U+0080–U+07FF | 2 | 110xxxxx 10xxxxxx |
| U+0800–U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
| U+10000–U+10FFFF | 4 | 11110xxx 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 (аллокация)Почему копирование обязательно в общем случае:
stringimmutable,[]bytemutable. Если быstring(b)делил память сb, изменениеbнарушило бы immutability строки. Поэтому — копия.[]rune(s)принципиально другой layout (4 байта на code point вместо 1–4 байт UTF-8), декодирование неизбежно.
Стоимость: O(n) по времени и памяти. Для горячего пути это значимо. []rune особенно дорог: для ASCII он раздувает память в 4 раза.
Оптимизации компилятора (без аллокации)#
Компилятор Go распознаёт несколько идиом и убирает копию/аллокацию:
string([]byte)как ключ map — поиск/чтение без аллокации:
m[string(b)]++ // не аллоцирует временную строку
v, ok := m[string(b)] // тожеКомпилятор знает, что временная строка живёт только во время lookup и не «утекает», поэтому делает поиск прямо по байтам.
range []byte(s)— итерация по байтам строки без аллокации слайса:
for i, c := range []byte(s) { ... } // []byte(s) не аллоцируетсяСравнение
string(b) == "literal"— без аллокации строки изb.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 != b—copyCheckпаникует:
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-байтов.
- Сравнение производительности
RuneCountInStringvslen([]rune(s))— первое не аллоцирует, второе аллоцирует весь слайс рун; senior выберет первое и объяснит почему.