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 в общем случае
копирует байты, потому что у типов разный контракт на изменяемость, а
не разная раскладка данных.
[]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♥"
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])) // "♥"
-
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
С 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.
[]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оставь для настоящего форматирования и не тащи в горячий цикл.
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 строит две новые строки целиком. Корректность:
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-анализ решил, что значение не утекает.
Зачем иммутабельность
-
Подстрока без копии.
s[a:b]отдаёт новый заголовок на тот же буфер: O(1), ноль аллокаций. Будь строки изменяемыми, так было бы небезопасно. - Конкурентность. Неизменяемое значение читается любым числом горутин без синхронизации. Это единственный «составной» тип Go с такой гарантией.
- Ключи мапы. Хеш считается один раз и не может «протухнуть» из-за изменения содержимого.
- Дешёвая передача. Копия строки весит 16 байт независимо от длины данных.
- Интернирование литералов. Компилятор кладёт одинаковые литералы в одну 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; их
применяют в парсерах, где известно, что буфер сразу
выбрасывается.»
| Тип | Заголовок | Изменяемость | Элемент |
|---|---|---|---|
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(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(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 ловится паникой). На мегабайтной строке одна
лишняя копия чувствуется.
Внутри лежит 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.
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
-
Аллокации.
ToLowerсоздаёт две новые строки;EqualFoldидёт по обеим строкам параллельно и не аллоцирует вообще. -
Ранний выход. На первом различии сразу
false, остаток не читается. - Корректность. 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.