voidlearn
 

1.4Строки

Свяжем байтовое представление строки с Unicode, стоимостью конверсий и удержанием памяти.

String header: что физически лежит в переменной

Как и у слайса, у строки есть заголовок, только полей в нём не три, а два.

За типом string стоит runtime.stringStruct: указатель на байты и длина в байтах. Ровно 16 байт на 64-битной платформе. Ёмкости нет, и она не нужна: строка не растёт.

// runtime/string.go
type stringStruct struct {
    str unsafe.Pointer   // указатель на первый байт
    len int              // длина в байтах, не в символах
}

s := "Привет"
fmt.Println(unsafe.Sizeof(s))   // 16 - размер заголовка, а не данных
fmt.Println(len(s))             // 12 - длина буфера в байтах

Сравни со слайсом: у []byte три слова (ptr, len, cap), а у string только два. Различие единственное, но следствий у него много. Конверсия string ↔ []byte в общем случае копирует байты, потому что у типов разный контракт на изменяемость, а не разная раскладка данных.

Заголовок строки и общий байтовый буфер s := "Привет мир" str ptr len = 19 sub := s[0:12] str ptr len = 12 байтовый буфер (read-only секция или куча) П р и в е т (12 байт) пробел + мир подстрока НЕ копирует: тот же указатель, другая длина поэтому весь буфер жив, пока жива подстрока Конверсия в []byte - копия, потому что срез изменяем b := []byte(s) ptr len 19 cap 24 НОВЫЙ буфер в куче - независимая копия 19 байт b[0] = 'X' не трогает s. Обратно: string(b) - тоже копия. Ровно поэтому конверсии в горячем пути видны в профиле аллокаций.
Строка, подстрока и срез. Слайсинг строки бесплатен и разделяет буфер; конверсия в []byte обязана копировать, иначе изменяемый срез мог бы поменять «неизменяемую» строку.

Почему строки иммутабельны

Спецификация прямо запрещает менять байты строки: s[0] = 'X' не соберётся, компилятор скажет cannot assign to s[0]. Иммутабельность означает вот что: значение после создания уже нельзя переписать, рядом строят новое. На собесе назови хотя бы три причины, зачем это строкам:

  • Подстроки без копии. s[a:b] заводит новый заголовок на тот же буфер, ноль аллокаций. Будь строки изменяемыми, каждая подстрока требовала бы копии, иначе правки протекали бы наружу.
  • Безопасность в конкурентности. Неизменяемое значение читают из любого числа горутин без синхронизации. Среди «сложных» типов Go строка такая одна: безопасна по построению.
  • Ключи мап и константы. Хеш строки можно считать, не боясь, что содержимое поменяется под ногами. Строковые литералы компилятор кладёт в read-only секцию бинаря и переиспользует один буфер для одинаковых литералов. Запись туда вызвала бы segfault на уровне ОС.
  • Дешёвое копирование значения. Строка уходит в функцию копией в 16 байт, хоть там мегабайт, хоть два символа.
s := "hello"
// s[0] = 'H'                  // ошибка компиляции: cannot assign to s[0]

// Изменить можно через []byte или []rune:
b := []byte(s)
b[0] = 'H'
s2 := string(b)                // "Hello" - новая строка, старая цела

// Литералы шарят память:
a1 := "hello"
a2 := "hello"
// unsafe: оба указывают в одну read-only область бинаря

// Через unsafe писать нельзя: сегфолт или порча памяти
// p := unsafe.StringData(s); *p = 'H'   // SIGSEGV: write to read-only section
Формулировка на собес

«Строка работает как immutable view на байты: указатель + длина. Иммутабельность нужна, чтобы подстроки, ключи мап и передача между горутинами были бесплатны и безопасны. Цена: любое изменение содержимого требует новой аллокации, поэтому в горячем пути строки собирают через strings.Builder, а не конкатенацией.»

UTF-8, руны и len

Go хранит строки в UTF-8, и это решение авторов языка: Кен Томпсон и Роб Пайк придумали саму кодировку. Длина у неё переменная. ASCII занимает 1 байт, кириллица и большинство европейских алфавитов по 2, CJK и большинство символов по 3, эмодзи и редкие знаки по 4.

В Go тип rune хранит кодовую точку (просто псевдоним int32), а в строке лежат её байты в кодировке UTF-8.

Диапазон кодовых точек Байт Схема битов Примеры
U+0000 … U+007F 1 0xxxxxxx ASCII: A, 9, пробел
U+0080 … U+07FF 2 110xxxxx 10xxxxxx кириллица, греческий, иврит
U+0800 … U+FFFF 3 1110xxxx 10xxxxxx 10xxxxxx CJK, ♥ (U+2665)
U+10000 … U+10FFFF 4 11110xxx 10xxxxxx ×3 эмодзи 😀 (U+1F600)

Работает это так: первый байт последовательности всегда отличим от продолжающих (продолжающие начинаются с 10). Поэтому по UTF-8 можно идти назад и находить границу символа из любой позиции. И ASCII-подстрока никогда не совпадёт «случайно» с куском многобайтового символа, так что strings.Contains(s, "/") корректен без всякого Unicode-парсинга.

s := "Go♥8" - байты, индексы, руны байты (len(s) == 8) 47 6f e2 99 a5 f0 9f 98 80 0 1 2 3 4 5 6 7 8 байтовые индексы руны (RuneCountInString == 4) G o U+2665 (3 байта) U+1F600 (4 байта) for i, r := range s → i принимает значения 0, 1, 2, 5 - это БАЙТОВЫЕ смещения начал рун s[2] == 0xe2 - байт, а не символ. fmt.Printf("%c", s[2]) напечатает мусор []rune(s) - новый массив из 4 значений int32 = 16 байт, аллокация len - байты · RuneCountInString - кодовые точки · «символы как их видит человек» - вообще третье число
Три разные длины одной строки. Восемь байт, четыре руны, четыре графемы. Для эмодзи-флагов и букв с диакритикой третье число снова разойдётся со вторым.
s := "Go♥"

fmt.Println(len(s))                     // 5 - байты
fmt.Println(utf8.RuneCountInString(s))  // 3 - руны

fmt.Printf("%T %v\n", s[0], s[0])       // uint8 71    - s[i] это байт
fmt.Printf("%c\n", s[2])                // â - байт 0xE2 напечатан как руна U+00E2,
                                        // это не «♥»: один байт из трёх

// По символам идём через range: он декодирует UTF-8
for i, r := range s {
    fmt.Printf("байт %d: руна %c (U+%04X, %d байт)\n", i, r, r, utf8.RuneLen(r))
}
// байт 0: руна G (U+0047, 1 байт)
// байт 1: руна o (U+006F, 1 байт)
// байт 2: руна ♥ (U+2665, 3 байт)

// По байтам, когда нужны именно байты (парсеры, ASCII-протоколы):
for i := 0; i < len(s); i++ {
    _ = s[i]                            // uint8
}

// По «символам» индексируем только через []rune, ценой аллокации:
rs := []rune(s)
fmt.Println(string(rs[2]))              // "♥"
Три ошибки, которые ловят на не-ASCII
  • s[i] в цикле по «символам»: на кириллице получишь половинки букв.
  • s[:10] для «обрезать до 10 символов» режет по байтам и легко разрубит руну пополам, а в выводе появится U+FFFD (replacement character). Режь по границе руны: string([]rune(s)[:10]) или пройдись range и запомни смещение.
  • strings.ToUpper для турецкого i и немецкого ß даёт не то, что ожидает носитель языка. Для локале-зависимых операций бери golang.org/x/text/cases.
Руна ≠ то, что видит человек

Руна хранит кодовую точку, а человек видит графемный кластер, и это не одно и то же. Флаг «🇷🇺» собран из двух рун (два regional indicator), «é» бывает одной руной (U+00E9) или двумя (e + U+0301 combining acute), а семейное эмодзи «👨‍👩‍👧» склеено из пяти рун zero-width joiner'ами. Поэтому len([]rune(s)) считает не «количество символов». За настоящими графемами иди в библиотеку вроде rivo/uniseg. Упомянешь этот нюанс на собесе, зачтётся.

Конверсии string ↔ []byte ↔ []rune и их цена

Конверсия Что происходит Стоимость
[]byte(s) malloc + memmove len байт O(n), 1 аллокация (кроме оптимизируемых случаев)
string(b) malloc + memmove len байт O(n), 1 аллокация
[]rune(s) декодирование UTF-8 + новый массив int32 O(n), 1 аллокация, до 4× памяти
string(rs) кодирование каждой руны в UTF-8 O(n), 1 аллокация
s[a:b] новый заголовок, тот же буфер O(1), 0 аллокаций
string(r), r - руна 1–4 байта в новой строке O(1), возможна аллокация
string(i), i - int не «число в строку»! это кодовая точка go vet ругается - нужен strconv.Itoa

В нескольких частных случаях компилятор копию убирает, и на собесе про них любят добить:

// 1. Ключ мапы: []byte → string без аллокации
var m map[string]int
b := []byte("key")
_ = m[string(b)]                 // компилятор не аллоцирует: временная строка не утекает

// 2. Сравнение
if string(b) == "hello" { }      // без аллокации

// 3. range по string(b)
for _, r := range string(b) { }  // без аллокации

// 4. Конкатенация в маленькие строки: стековый буфер на 32 байта
s := "a" + string(b)             // может уложиться в tmpBuf на стеке

// 5. Обратно: []byte(s) в аргументе Write, который не сохраняет срез -
//    escape-анализ отрабатывает, но копия обычно всё равно делается,
//    потому что компилятор не знает, что Write не запомнит срез
w.Write([]byte(s))               // аллокация есть
io.WriteString(w, s)             // а вот так - без аллокации, если w реализует StringWriter
unsafe-конверсии: когда это законно

С Go 1.20 в пакете unsafe есть официальные unsafe.String(ptr, len), unsafe.StringData(s), unsafe.Slice(ptr, len), unsafe.SliceData(s). Они конвертируют без копии. Законно это только тогда, когда ты гарантируешь, что байты после превращения в строку никто не изменит (скажем, буфер сразу выбрасывается). Так пишут декодеры и парсеры в горячем пути. В обычном коде не надо: ошибка обойдётся тихой порчей данных, которую не поймает даже race-детектор.

// Zero-copy, Go 1.20+. Использовать, только понимая последствия.
func bytesToString(b []byte) string {
    if len(b) == 0 { return "" }
    return unsafe.String(unsafe.SliceData(b), len(b))
}
func stringToBytes(s string) []byte {
    if s == "" { return nil }
    return unsafe.Slice(unsafe.StringData(s), len(s))   // писать в результат нельзя
}

Конкатенация: почему «плюс» в цикле квадратичен

Каждый a + b обязан создать новую строку: выделить буфер длины len(a)+len(b) и скопировать туда оба куска. В цикле на n итераций набегает 1 + 2 + 3 + … + n скопированных байт, то есть O(n²) по времени и n аллокаций. Каждая тут же становится мусором для GC.

s += x в цикле: каждая итерация - новый буфер и копия всего накопленного итерация 1: аллок 4 Б, копируем 4 Б итерация 2: аллок 8 Б, копируем 8 Б - первый буфер стал мусором итерация 3: аллок 12 Б, копируем 12 Б итерация 4: аллок 16 Б, копируем 16 Б всего скопировано 4+8+12+16 = O(n²) байт, создано n мусорных буферов strings.Builder: один растущий буфер, удвоение ёмкости, ноль копий строки cap 8: пишем, места хватает cap 16: одно удвоение с копией - как у append амортизированная O(n) по времени, log(n) аллокаций; String() отдаёт буфер БЕЗ копии
Плюс против Builder. Внутри Builder держит []byte с обычной стратегией роста append, а String() делает unsafe-конверсию без копии: после неё запись в буфер уже запрещена.
Способ Сложность Аллокаций Когда использовать
a + b + c одним выражением O(n) 1 всегда ок: компилятор вызывает concatstrings один раз на всё выражение
s += x в цикле O(n²) n никогда при неизвестном n
strings.Builder амортиз. O(n) log n (или 1 с Grow) дефолт для сборки строки в цикле
bytes.Buffer амортиз. O(n) log n если нужен io.Writer и/или чтение из буфера
strings.Join O(n) 1 когда все куски уже лежат в слайсе
fmt.Sprintf O(n) + рефлексия 2–4 форматирование, не конкатенация - в 5–10 раз медленнее
append([]byte, ...) амортиз. O(n) log n самый быстрый вариант, если итог всё равно нужен как []byte
// Плохо: O(n²)
func joinBad(parts []string) string {
    s := ""
    for _, p := range parts { s += p }     // n аллокаций, n²/2 скопированных байт
    return s
}

// Хорошо: один буфер, одна аллокация
func joinGood(parts []string) string {
    var b strings.Builder
    n := 0
    for _, p := range parts { n += len(p) }
    b.Grow(n)                              // сразу нужный размер, без перевыделений
    for _, p := range parts { b.WriteString(p) }
    return b.String()                      // без копии: unsafe-конверсия внутри
}

// Ещё лучше, если куски уже в слайсе
func joinBest(parts []string) string {
    return strings.Join(parts, "")         // ровно одна аллокация нужного размера
}

// У форматирования другая задача и другая цена:
_ = fmt.Sprintf("%s=%d", k, v)             // рефлексия + интерфейсный боксинг
_ = k + "=" + strconv.Itoa(v)              // в разы быстрее для двух-трёх кусков
// Бенчмарк, который стоит уметь написать на доске (Go 1.24+, b.Loop)
func BenchmarkPlus(b *testing.B) {
    parts := make([]string, 1000)
    for i := range parts { parts[i] = "chunk" }
    for b.Loop() {
        s := ""
        for _, p := range parts { s += p }
        _ = s
    }
}

func BenchmarkBuilder(b *testing.B) {
    parts := make([]string, 1000)
    for i := range parts { parts[i] = "chunk" }
    for b.Loop() {
        var sb strings.Builder
        sb.Grow(5000)
        for _, p := range parts { sb.WriteString(p) }
        _ = sb.String()
    }
}
// Типичный порядок величин на 1000 кусков по 5 байт:
// BenchmarkPlus-8       ~  1_300_000 ns/op   2_500_000 B/op   999 allocs/op
// BenchmarkBuilder-8    ~      8_000 ns/op       5_000 B/op     1 allocs/op
// BenchmarkJoin-8       ~      6_000 ns/op       5_000 B/op     1 allocs/op
// BenchmarkSprintf-8    ~     70_000 ns/op      15_000 B/op    ~5 allocs/op
Правило выбора
  • Кусков заранее 2–3, хватит + в одном выражении.
  • Куски уже в слайсе, бери strings.Join.
  • Собираешь в цикле, только strings.Builder и сразу Grow.
  • Когда нужен io.Writer (шаблоны, HTTP-ответ), подойдёт bytes.Buffer, а ещё лучше писать прямо в http.ResponseWriter.
  • fmt.Sprintf оставь для настоящего форматирования и не тащи в горячий цикл.
Почему Builder нельзя копировать

strings.Builder хранит поле addr *Builder, указатель на самого себя. При каждой записи он проверяет b.addr == b; если структуру скопировали, проверка падает с panic: strings: illegal use of non-zero Builder copied by value. Так две копии не смогут писать в один буфер и портить данные. Отсюда правило: Builder передавай только по указателю, а go vet (copylocks) за этим следит. То же самое, кстати, у sync.Mutex и sync.WaitGroup.

Утечка памяти через подстроку

Подстрока делит буфер с оригиналом. Пока жива хотя бы одна короткая подстрока, весь исходный буфер держится в памяти. Механика ровно та же, что у слайсов, и вопрос задают парой.

// Читаем файл на 100 МБ, берём из него первые 10 символов и кладём в кэш
func loadKey(path string) string {
    data, _ := os.ReadFile(path)   // 100 МБ в куче
    s := string(data)              // ещё 100 МБ (копия)
    return s[:10]                  // заголовок len=10 на буфер 100 МБ
}
// data соберётся, а вот буфер s - нет: на него смотрит возвращённая подстрока.
// 100 МБ живут ради 10 байт.

// Вариант 1: форсируем копию через конверсию туда-обратно
return string([]byte(s[:10]))      // новый буфер ровно на 10 байт

// Вариант 2 (Go 1.18+): strings.Clone, явно и без лишней конверсии
return strings.Clone(s[:10])

// Вариант 3: не создавать большую строку вообще
f, _ := os.Open(path)
buf := make([]byte, 10)
io.ReadFull(f, buf)
return string(buf)
Где это встречается в проде
  • Парсинг больших ответов. Достали из JSON-строки один id через слайсинг, удержали весь ответ.
  • strings.Split и strings.Fields возвращают подстроки, а не копии. Разобрали лог-строку на 10 КБ, положили одно поле в долгоживущую мапу и держим 10 КБ на каждую запись.
  • bytes.Split: та же история со срезами.
  • HTTP-заголовки и query. net/http часто отдаёт подстроки буфера запроса.

Симптом: RSS растёт линейно с числом записей в кэше, хотя суммарная длина хранимых строк копеечная. Смотри в pprof -inuse_space: там видно, что живут гигантские []byte или строковые буферы, а не сами записи.

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

== для строк делает побайтовое сравнение содержимого, а не адресов. Под капотом (runtime.memequal) сначала идут длины (разошлись, мгновенный отказ), затем указатели (совпали, строки идентичны, ноль работы), и только потом байты машинными словами. Оператор < сравнивает лексикографически, по байтам, и для UTF-8 это совпадает с порядком кодовых точек.

a, b := "hello", "hello"
fmt.Println(a == b)                    // true - сравнение содержимого

fmt.Println("apple" < "banana")        // true - лексикографически по байтам
fmt.Println("Z" < "a")                 // true - 0x5A < 0x61, регистр влияет
fmt.Println("Ёж" < "Яблоко")           // true, но по байтам UTF-8, а не по алфавиту:
                                       // «Ё» это U+0401 (0xD0 0x81), «Я» - U+042F (0xD0 0xAF)

// strings.Compare есть, но обычно не нужен: он для sort-интерфейсов
fmt.Println(strings.Compare(a, b))     // 0 (-1, 0, 1)

// Без учёта регистра сравниваем не через ToLower:
fmt.Println(strings.EqualFold("Go", "GO"))        // true
fmt.Println(strings.EqualFold("Straße", "STRASSE")) // false - case folding не разворачивает ß
fmt.Println(strings.EqualFold("ПРИВЕТ", "привет"))  // true - работает и с кириллицей
Задача Как Почему не иначе
Точное равенство a == b быстрее всего, сравнивает длины первыми
Регистронезависимо strings.EqualFold(a, b) ToLower(a) == ToLower(b) делает 2 аллокации и неверен для Unicode case folding
Сортировка slices.Sort / < байтовый порядок; для «человеческого» алфавита нужен x/text/collate
Префикс/суффикс strings.HasPrefix / HasSuffix s[:n] == p паникует, если n > len(s)
Секреты, токены, HMAC subtle.ConstantTimeCompare == выходит на первом различии - таймингом можно подобрать значение
Юникод-эквивалентность norm.NFC.String(a) == norm.NFC.String(b) «é» в NFC и NFD - разные байты при одинаковом смысле
Почему EqualFold, а не ToLower

Три причины. Скорость: EqualFold идёт по строкам параллельно и выходит на первом различии, ничего не аллоцируя, а ToLower строит две новые строки целиком. Корректность: Unicode задаёт отдельную операцию «simple case folding», спроектированную ровно под сравнение без учёта регистра, и она не равна «привести к нижнему регистру» (у греческой сигмы две строчные формы, турецкая I отображается по-своему). И третье, честность имени: функция говорит, что сравнивает, а не преобразует.

Оговорка: EqualFold делает именно simple folding, поэтому "ß" и "ss" равными не считает. Для полного folding бери golang.org/x/text.

Что выведет код?

// (A)
s := "мир"
fmt.Println(len(s))
fmt.Println(len([]rune(s)))
fmt.Println(s[0])
fmt.Println(string(s[0]))
fmt.Println(string(rune(1055)))

for i := range s {
    fmt.Print(i, " ")
}
// (B)
s := "hello"
b := []byte(s)
b[0] = 'H'
fmt.Println(s, string(b))

x := 65
fmt.Println(string(rune(x)))
fmt.Println(strconv.Itoa(x))

fmt.Println("a"+"b" == "ab")
var e string
fmt.Println(e == "", len(e))

(A) → 6 (три кириллические буквы по 2 байта), 3, 208 (первый байт буквы «м» в UTF-8 равен 0xD0), затем "Ð" (конверсия string(byte) трактует 208 как кодовую точку U+00D0), затем "П" (U+041F = 1055). Индексы из range: 0 2 4, то есть байтовые смещения начал рун, а не 0 1 2.

(B) → hello Hello: конверсия скопировала байты, оригинал цел. Затем "A" и "65": string(rune(65)) даёт символ с кодом 65, а strconv.Itoa(65) даёт текстовое представление числа. Дальше true (конкатенация констант вычисляется компилятором) и true 0: нулевое значение строки равно "", а не nil; строка не может быть nil.

Вопросы

7
Суть: строка - это два слова: указатель на байты и длина в байтах. Байты неизменяемы, поэтому подстроки бесплатны, строка безопасна между горутинами и годится в ключи мапы.

Структура

type stringStruct struct {
    str unsafe.Pointer   // на первый байт
    len int              // длина в байтах
}
unsafe.Sizeof("любая строка")   // 16 на amd64 - размер заголовка

От []byte отличие одно: нет cap. Ёмкость не нужна, строка не растёт. Данные лежат в read-only секции бинаря (для литералов), в куче (результат конкатенации, конверсии) или в стеке, если escape-анализ решил, что значение не утекает.

Зачем иммутабельность

  1. Подстрока без копии. s[a:b] отдаёт новый заголовок на тот же буфер: O(1), ноль аллокаций. Будь строки изменяемыми, так было бы небезопасно.
  2. Конкурентность. Неизменяемое значение читается любым числом горутин без синхронизации. Это единственный «составной» тип Go с такой гарантией.
  3. Ключи мапы. Хеш считается один раз и не может «протухнуть» из-за изменения содержимого.
  4. Дешёвая передача. Копия строки весит 16 байт независимо от длины данных.
  5. Интернирование литералов. Компилятор кладёт одинаковые литералы в одну read-only область; запись туда была бы SIGSEGV на уровне ОС.
s := "hello"
// s[0] = 'H'          // ошибка компиляции: cannot assign to s[0]
b := []byte(s); b[0] = 'H'; s2 := string(b)   // через копию
Чем добить

«Иммутабельность держится на контракте языка, а не на аппаратной защите. Через unsafe.StringData байты формально доступны на запись, но для литералов это сегфолт, а для строк из кучи тихая порча, которую не поймает даже race-детектор. С Go 1.20 для zero-copy конверсий есть официальные unsafe.String и unsafe.Slice; их применяют в парсерах, где известно, что буфер сразу выбрасывается.»

Суть: любая конверсия между этими тремя - O(n) с аллокацией, потому что у них разный контракт на изменяемость и разное представление. Исключения - несколько частных случаев, которые оптимизирует компилятор.
Тип Заголовок Изменяемость Элемент
string ptr + len (16 Б) нет байт
[]byte ptr + len + cap (24 Б) да байт (uint8)
[]rune ptr + len + cap (24 Б) да кодовая точка (int32)

Цена конверсий

  • []byte(s) и string(b) идут через mallocgc + memmove: O(n), одна аллокация. Копия обязательна, иначе изменяемый срез поменял бы «неизменяемую» строку.
  • []rune(s) декодирует UTF-8 целиком и строит массив int32: O(n) по времени и до 4× памяти для ASCII.
  • string(rs) кодирует каждую руну обратно: O(n), одна аллокация.
  • s[a:b] стоит ноль аллокаций, только новый заголовок.

Где компилятор убирает копию

m[string(b)]                       // ключ мапы - без аллокации
if string(b) == "hello" { }        // сравнение - без аллокации
for _, r := range string(b) { }    // range - без аллокации
switch string(b) { case "a": }     // switch - без аллокации
io.WriteString(w, s)               // вместо w.Write([]byte(s)) - без аллокации

Правило одно: если временная строка не переживает выражение, компилятор её не материализует. Но как только результат сохраняется в переменную, попадает в структуру или уходит в интерфейс, копия неизбежна.

Как избегать конверсий на практике

  • Выбери один тип и веди его через весь путь данных: []byte для I/O-пайплайнов, string для доменной логики.
  • Пользуйся зеркальными API: bytes.Contains вместо strings.Contains(string(b), …); strconv.AppendInt вместо []byte(strconv.Itoa(…)).
  • В горячем пути выручат unsafe.String и unsafe.Slice, но только когда профиль это доказал и инварианты понятны.
Ловушка: string(число)

string(65) даёт не «65» в строку, а символ с кодом 65, то есть "A". Числа переводит strconv.Itoa или strconv.FormatInt. С Go 1.15 go vet помечает string(int) как подозрительное, а конверсию из нетипизированной константы (string(65)) новые версии без явного rune вообще запрещают.

Суть: len(s) - число байт. Число кодовых точек даёт utf8.RuneCountInString(s). Корректная итерация по символам - только for i, r := range s, который декодирует UTF-8.
s := "Привет"
len(s)                        // 12 - кириллица по 2 байта
utf8.RuneCountInString(s)     // 6
len([]rune(s))                // 6, но с аллокацией: ради счёта так не делают

Три способа пройти по строке

// 1) По рунам: range декодирует UTF-8, i это байтовое смещение
for i, r := range s {
    fmt.Printf("%d:%c ", i, r)     // 0:П 2:р 4:и 6:в 8:е 10:т
}

// 2) По байтам, когда работаешь с бинарём или ASCII-протоколом
for i := 0; i < len(s); i++ {
    _ = s[i]                       // uint8
}

// 3) Через []rune, когда нужен произвольный доступ по индексу символа
rs := []rune(s)
_ = rs[3]                          // аллокация: оправдана, только если индексируешь много раз

Что важно про UTF-8

  • Кодировка переменной длины: 1 байт на ASCII, 2 на кириллицу и греческий, 3 на CJK и большинство символов, 4 на эмодзи.
  • Продолжающие байты всегда начинаются с 10, поэтому границу символа можно найти из любой позиции и идти по строке назад.
  • Подстрока ASCII не может «случайно» совпасть с куском многобайтового символа, поэтому strings.Split(s, "/") безопасен без Unicode-парсинга.
  • Невалидные байты range отдаёт как utf8.RuneError (U+FFFD) длиной 1 байт, не паникуя.

Обрезка строки по длине - типичная задача

// Плохо: разрежет руну пополам, в выводе появится U+FFFD
func truncBad(s string, n int) string { if len(s) > n { return s[:n] }; return s }

// Хорошо: режем по границе руны, без аллокации
func trunc(s string, n int) string {
    if utf8.RuneCountInString(s) <= n { return s }
    cnt := 0
    for i := range s {              // i - смещение начала очередной руны
        if cnt == n { return s[:i] }
        cnt++
    }
    return s
}
Руна не равна «символу»

len([]rune(s)) считает кодовые точки, а не то, что человек видит как символ. «é» бывает одной руной (U+00E9) или двумя (e + combining acute). Флаг «🇷🇺» разбирается на две руны. В семейном эмодзи их пять, склеенных ZWJ. Для настоящего «сколько символов на экране» нужны графемные кластеры, например библиотека rivo/uniseg. Скажешь это на собесе, и станет видно, что ты понимаешь границы модели.

Суть: байт (byte, он же uint8). Индексация строки - это индексация её байтового буфера, никакого декодирования UTF-8 не происходит.
s := "Привет"
fmt.Printf("%T %v\n", s[0], s[0])   // uint8 208 - первый байт буквы "П" (0xD0)
fmt.Printf("%c\n", s[0])            // Ð - 208 прочитан как кодовая точка U+00D0
fmt.Println(s[0:2])                 // "П" - два байта вместе дают букву

// А range декодирует и даёт rune:
for i, r := range s {
    fmt.Printf("%d %T %c\n", i, r, r)   // 0 int32 П ; 2 int32 р ; ...
    break
}

Почему так

Строка хранит байты, и len(s) считает именно их. Если бы s[i] возвращало руну, индексация стала бы O(n) (пришлось бы декодировать всё от начала), а len перестал бы соответствовать диапазону индексов. Go выбрал прямую байтовую модель, дешёвую и предсказуемую. Расплата в том, что за символами надо ходить через range или []rune.

Практические следствия

  • Для ASCII разницы нет: там байт и есть символ, и s[i] работает как ожидаешь.
  • Для не-ASCII s[i] в цикле даст мусор: половинки многобайтовых последовательностей.
  • s[a:b] режет по байтам и легко разорвёт руну, а при печати вылезет U+FFFD.
  • «Первый символ» берут так:
r, size := utf8.DecodeRuneInString(s)   // руна и её длина в байтах, без аллокации
fmt.Printf("%c занимает %d байт\n", r, size)   // П занимает 2 байт

// Или через range с немедленным выходом:
for _, r := range s { first = r; break }

// А не так:
// first := rune(s[0])   // 208 - это не буква
Симметрия с []byte и []rune

[]byte(s)[i] даёт тот же байт, ничего не меняется. []rune(s)[i] даёт руну, но ценой аллокации всего массива. Нужен доступ по индексу символа один раз, бери utf8.DecodeRuneInString; нужен много раз, сконвертируй в []rune и работай с ним.

Суть: строка иммутабельна, поэтому каждый + создаёт новый буфер и копирует всё накопленное - в цикле это O(n²) и n аллокаций. strings.Builder копит в одном растущем []byte и отдаёт строку без копии.

Что происходит при s += x

Компилятор вызывает runtime.concatstrings: считает суммарную длину, выделяет новый буфер, копирует в него оба куска. Старая строка становится мусором. На n итерациях копируется n(n+1)/2 байт и остаётся n мусорных буферов, которые вдобавок нагружают GC.

Оговорка: a + b + c + d одним выражением превращается в один вызов concatstrings, то есть в одну аллокацию. Плохо именно накопление в цикле.

Способ Время Аллокаций Комментарий
s += x в цикле O(n²) n худший вариант
strings.Builder O(n) аморт. log n дефолт
Builder + Grow(n) O(n) 1 лучший вариант при известном размере
strings.Join O(n) 1 если куски уже в слайсе
bytes.Buffer O(n) аморт. log n +1 копия в String(), зато это io.Writer
fmt.Sprintf O(n) + рефлексия 2–4 в 5–10 раз медленнее - это форматирование, а не склейка
var b strings.Builder
b.Grow(totalLen)                 // одна аллокация на всё
for _, p := range parts {
    b.WriteString(p)             // append в []byte, без копии строки
}
return b.String()                // unsafe-конверсия без копии буфера

Почему Builder быстрее bytes.Buffer для строк

bytes.Buffer.String() делает string(b.buf[off:]), то есть честную копию. strings.Builder.String() возвращает unsafe.String(&b.buf[0], len(b.buf)) без копии, и это безопасно: Builder гарантирует, что после String() в буфер уже никто не пишет (запись после копирования Builder ловится паникой). На мегабайтной строке одна лишняя копия чувствуется.

Builder нельзя копировать

Внутри лежит addr *Builder, указатель на самого себя. При записи проверяется b.addr == b, и если структуру скопировали, вылетит panic: strings: illegal use of non-zero Builder copied by value. Передавай Builder только по указателю; go vet ловит такие копии анализатором copylocks.

Как отвечать

«Два-три куска, просто +. Всё уже в слайсе, strings.Join. Собираем в цикле, strings.Builder с Grow. Нужен io.Writer, берём bytes.Buffer или пишем прямо в ResponseWriter. Sprintf только когда реально нужно форматирование.» И назови порядок цифр: на 1000 кусках Builder примерно в 150 раз быстрее плюса и делает 1 аллокацию вместо 1000.

Суть: подстрока - это новый заголовок на тот же буфер. Пока жива десятибайтовая подстрока, стомегабайтный буфер не может быть собран GC.
func extractID(body string) string {
    return body[10:20]    // 10 байт, а держат весь body
}
cache[key] = extractID(hugeBody)   // в кэше «10 байт», в куче - весь body

Почему GC не может помочь

Сборщик работает с гранулярностью объекта: буфер строки лежит в куче одним блоком. Если на него указывает хотя бы один живой указатель, жив весь блок. Обрезать его GC не умеет: в Go нет перемещающего сборщика и нет «частичного освобождения» объекта.

Три способа починить

// 1) strings.Clone - Go 1.18+, самый честный способ
small := strings.Clone(body[10:20])

// 2) Конверсия туда-обратно, так жили до Clone
small = string([]byte(body[10:20]))

// 3) Не создавать большую строку вообще: читать потоково
sc := bufio.NewScanner(r)
for sc.Scan() {
    line := sc.Text()      // Text() уже делает копию, Bytes() нет: буфер переиспользуется
}

Где это ловится в проде

  • strings.Split, strings.Fields, strings.TrimSpace возвращают подстроки, а не копии. Разобрали лог-строку на 8 КБ, сохранили одно поле в мапу, держим 8 КБ.
  • bytes.Split и bufio.Scanner.Bytes(): то же самое со срезами, причём у Scanner буфер ещё и переиспользуется между строками, так что данные протухнут.
  • Парсинг HTTP-ответа: достали id слайсингом из тела на мегабайт, удержали мегабайт на каждую запись.
  • regexp.FindStringSubmatch тоже отдаёт подстроки исходной строки.

Как диагностировать

$ go tool pprof -inuse_space http://localhost:6060/debug/pprof/heap
(pprof) top
# симптом: почти вся живая память в одном месте аллокации большого буфера
# (os.ReadFile, io.ReadAll, http body), хотя логически хранится мало
Связь со слайсами

Механика ровно та же, что у []T, и на собесе вопросы часто идут парой. Отличие одно: у слайса есть full slice expression s[low:high:max], которая ограничивает cap, а у строки такого нет, потому что нет и самого cap. Строку лечат только явной копией (strings.Clone).

Суть: == сравнивает содержимое побайтово (сначала длины, потом указатели, потом байты машинными словами). Регистронезависимо - только strings.EqualFold, а не ToLower(a) == ToLower(b).

Как устроено ==

Компилятор разворачивает сравнение строк в проверку длин (разошлись, сразу false), затем в сравнение указателей (совпали, сразу true), и только потом в runtime.memequal, который идёт по машинным словам, с SIMD там, где он доступен. Оператор < сравнивает лексикографически по байтам; для UTF-8 это совпадает с порядком кодовых точек, но не с алфавитным порядком языка.

"Z" < "a"                  // true: 0x5A < 0x61 - заглавные раньше строчных
"ёлка" < "яблоко"          // сравниваются байты UTF-8, а не «русский алфавит»
// для человеческого порядка нужен golang.org/x/text/collate

Почему EqualFold, а не ToLower

  1. Аллокации. ToLower создаёт две новые строки; EqualFold идёт по обеим строкам параллельно и не аллоцирует вообще.
  2. Ранний выход. На первом различии сразу false, остаток не читается.
  3. Корректность. Unicode определяет отдельную операцию case folding, спроектированную именно для сравнения. Она не равна «привести к нижнему регистру»: у греческой сигмы две строчные формы (σ и ς), у турецкого I своя пара, у немецкого ß отдельные правила.
strings.EqualFold("Go", "GO")           // true
strings.EqualFold("ПРИВЕТ", "привет")   // true
strings.EqualFold("Σίσυφος", "ΣΊΣΥΦΟΣ") // true - обе сигмы свернутся правильно
strings.EqualFold("Straße", "STRASSE")  // false - simple folding не разворачивает ß в ss

Когда нужен НЕ ==

Задача Инструмент
Регистронезависимо strings.EqualFold
Пароль, токен, HMAC-подпись crypto/subtle.ConstantTimeCompare - == выходит на первом различии, и по времени ответа можно подобрать значение побайтово
«é» в разных нормализациях norm.NFC.String(a) == norm.NFC.String(b)
Человеческая сортировка x/text/collate с нужной локалью
Префикс/суффикс strings.HasPrefix - s[:n] паникует при n > len(s)
Сравнение []byte bytes.Equal - == для срезов не компилируется
Классическая уязвимость

Сравнение API-токена через token == expected открывает timing attack: memequal выходит на первом несовпавшем байте, и по разнице во времени ответа токен подбирают за линейное число запросов вместо экспоненциального. Для секретов всегда subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1. На вопросе «как сравнить две строки» этот ответ отделяет мидла от джуна.

Что стоит запомнить

  • len(string) считает байты UTF-8, а не руны.
  • Индекс строки возвращает байт.
  • Подстрока может удерживать большой исходный буфер.

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

  • Не считай rune графемой.
  • Конкатенация в цикле многократно копирует префикс.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Проследи значения

Что напечатает фрагмент? Объясни, почему получился такой результат.

s := "é"
fmt.Println(len(s), len([]rune(s)))
Ответ и разбор

Вывод 2 1: строка содержит два байта UTF-8, декодируемых в одну руну.

Код-ревью

2. Найди ошибку и исправь

parts содержит тысячи коротких строк. Замени накопление так, чтобы не копировать весь префикс на каждой итерации.

var result string
for _, part := range parts { result += part }
Ответ и разбор

Используй strings.Builder: var b strings.Builder; for _, part := range parts { b.WriteString(part) }; result := b.String(). Цикл с += создаёт последовательность всё более длинных строк и суммарно копирует квадратичный объём данных.

Применение

3. Реши задачу

Нужно получить первые три байта строки, чтобы разобрать бинарный заголовок. Почему s[:3] допустимо, а для первых трёх символов текста неверно?

Ответ и разбор

s[:3] индексирует байты и подходит, если формат определён в байтах и граница проверена. Для первых трёх Unicode code point декодируй руны, например []rune(s)[:3] после проверки длины. Если нужны пользовательские графемы, руны недостаточны: комбинируемые знаки требуют сегментации графем.

Применение

4. Реши задачу

HTTP-обработчик сохраняет первые 20 байт из большого request body. Срез живёт дольше запроса. Как не удерживать весь исходный массив?

Ответ и разбор

Скопируй нужную часть в отдельный буфер: saved := bytes.Clone(body[:20]) (или выдели слайс и вызови copy). Маленький подслайс может ссылаться на большой массив и удерживать его до сборки saved.