Строки в Go
Строка в Go – это read-only последовательность байт. Ключевое слово здесь – байт: строка ничего не знает про буквы и символы, она хранит именно байты. К этому мы еще вернемся, а начнем с внутреннего устройства.
Строка под капотом
Строка – структура размером в два машинных слова:
- Указатель на нижележащий массив байт.
- Количество байт в строке (длина).
// runtime/string.go
type stringStruct struct {
str unsafe.Pointer
len int
}goЕсли вы читали статью про слайсы, то структура покажется знакомой: это слайс байт без поля cap. Капасити строке не нужно – строка не может расшириться.
Посмотрим на строку под капотом. Указатель на нижележащий массив можно получить функцией StringData() из пакета unsafe, а длину – обычным len(). Напишем хелпер:
package main
import (
"fmt"
"unsafe"
)
func inspectString(str string) {
fmt.Println("addr:", unsafe.Pointer(unsafe.StringData(str)))
fmt.Println("len:", len(str))
fmt.Println()
}gostr := "Hello"
inspectString(str)goaddr: 0x1c985e6
len: 5У зеро вэлью строки ("") указатель равен nil, а длина – нулю:
var str string
inspectString(str)goaddr: <nil>
len: 0Строго говоря, nil-указатель – деталь реализации рантайма gc: документация unsafe.StringData говорит лишь, что для пустой строки результат не определен и может быть nil.
Копирование строки
При копировании строки копируется только хедер – два машинных слова. Нижележащий массив не копируется:
str := "Hello"
strCopy := str
inspectString(str)
inspectString(strCopy)goaddr: 0x1c985e6
len: 5
addr: 0x1c985e6
len: 5Обе строки указывают на один и тот же массив байт. Поэтому копирование строки – дешевая операция: неважно, 5 байт в строке или 5 мегабайт, копируются всегда только указатель и длина.
Подстрока
Взятие подстроки устроено так же, как слайс от слайса: новый хедер указывает на тот же нижележащий массив, но с другим смещением и длиной:
str := "Hello, world"
sub := str[7:]
inspectString(str)
inspectString(sub)
addr := unsafe.Add(unsafe.Pointer(unsafe.StringData(str)), 7)
fmt.Println("Адрес 8-го байта оригинальной строки:", addr)goaddr: 0x1c9941a
len: 12
addr: 0x1c99421
len: 5
Адрес 8-го байта оригинальной строки: 0x1c99421Подстрока sub указывает внутрь массива строки str. Никакого копирования байт не происходит – операция str[7:] выполняется за константное время. У этой эффективности есть и обратная сторона, о ней поговорим в конце статьи.
Иммутабельность
Одно из важных свойств строк в Go: строка иммутабельна, то есть неизменяема. Байты строки нельзя поменять после ее создания. Такой код не скомпилируется:
str := "Hello"
str[0] = 'Y'gocannot assign to str[0] (neither addressable nor a map index expression)Иммутабельность делает строки удобными:
- Строку можно безопасно передавать куда угодно – в другие функции, горутины, класть в мапы. Никто не сможет изменить строку, а значит клиентскому коду не надо от этого защищаться.
- Копирование дешевое. Как мы видели выше, копируется только хедер. Это безопасно ровно потому, что нижележащий массив гарантированно не изменится.
- Строки можно использовать как ключи мапы. Хеш строки не устареет, потому что содержимое не может измениться.
На иммутабельности держится и все остальное поведение строк в Go. Например, конвертации между string и []byte копируют память – почему, разберем в разделе про конвертации.
Строка – это байты
В начале статьи мы сказали, что строка – это последовательность байт, а не символов. Убедимся в этом: выведем каждый байт строки в шестнадцатеричной системе счисления.
str := "Hello"
for i := 0; i < len(str); i++ {
fmt.Printf("%X ", str[i])
}go48 65 6C 6C 6FБайт 48 – это код латинской буквы H в ASCII, 65 – буквы e, и так далее. Для английского текста все ожидаемо: одна буква – один байт.
Теперь попробуем строку на русском языке:
str := "Привет"
for i := 0; i < len(str); i++ {
fmt.Printf("%X ", str[i])
}goD0 9F D1 80 D0 B8 D0 B2 D0 B5 D1 82В строке из 6 букв оказалось 12 байт. Функция len() подтверждает:
fmt.Println(len("Hello"))
fmt.Println(len("Привет"))go5
12Почему так? Текст в строке хранится в кодировке UTF-8. В Юникоде каждому символу соответствует код-поинт – число от 0 до 0x10FFFF. UTF-8 кодирует один код-поинт переменным количеством байт: от одного до четырех. Латинские буквы из ASCII занимают один байт, кириллица – два.
В нашей строке 6 код-поинтов по 2 байта каждый:
len() возвращает именно количество байт, и работает за O(1) – длина лежит прямо в хедере строки.
Индексация тоже работает на уровне байт. Выражение str[i] возвращает i-й байт, а не i-й символ:
str := "Hello"
fmt.Println(str[0])
fmt.Printf("%T\n", str[0])go72
uint872 – это десятичное значение байта 48 в hex, код буквы H. Тип значения – uint8, для которого в Go есть алиас byte.
Откуда в строке берется UTF-8
Строка в Go может содержать любые байты – язык не требует, чтобы это был валидный UTF-8. Откуда тогда UTF-8 в наших примерах?
Из исходного кода. Спецификация требует, чтобы исходный код Go был текстом в UTF-8. Строковый литерал "Привет" – часть исходного кода, поэтому в скомпилированную программу он попадает как последовательность из 12 UTF-8 байт. Компилятор просто копирует байты литерала как есть.
Строковые литералы
Литералы бывают двух видов. Интерпретируемые – в двойных кавычках, в них работают эскейп-последовательности: \n, байтовые \xNN и юникодные \uNNNN. Сырые – в обратных кавычках, в них байты записываются как есть, без интерпретации:
interpreted := "первая строка\nвторая строка"
raw := `символы \n не интерпретируются`
fmt.Println(interpreted)
fmt.Println(raw)goпервая строка
вторая строка
символы \n не интерпретируютсяС помощью \xNN можно записать строку побайтово. Первый литерал ниже эквивалентен "Hello", второй – "Привет": это те же 12 байт, что мы выводили выше.
fmt.Println("\x48\x65\x6C\x6C\x6F")
fmt.Println("\xD0\x9F\xD1\x80\xD0\xB8\xD0\xB2\xD0\xB5\xD1\x82")goHello
ПриветИтерация по строке
Мы выяснили, что str[i] возвращает байт. А что вернет for range? Проверим на строке Привет:
str := "Привет"
for i, v := range str {
fmt.Printf("i: %d, v: %v, v type: %T\n", i, v, v)
}goi: 0, v: 1055, v type: int32
i: 2, v: 1088, v type: int32
i: 4, v: 1080, v type: int32
i: 6, v: 1074, v type: int32
i: 8, v: 1077, v type: int32
i: 10, v: 1090, v type: int32Два интересных наблюдения:
- Индекс каждый раз увеличивается на 2.
- У значения тип
int32, а неbyte.
Дело в том, что for range итерируется по строке не по байтам, а по код-поинтам: на каждой итерации он декодирует из UTF-8 очередной код-поинт. Индекс – это позиция первого байта код-поинта. В нашей строке каждый код-поинт занимает 2 байта, поэтому индекс растет на 2.
Значение имеет тип rune – это алиас для int32. Почему именно int32? Код-поинты в Юникоде доходят до 0x10FFFF – это больше, чем помещается в 16 бит. Ближайший подходящий тип – 32-битный.
Выведем код-поинты в привычном юникодном виде с помощью директивы %#U:
str := "Привет"
for i, runeVal := range str {
fmt.Printf("%#U: %d\n", runeVal, i)
}goU+041F 'П': 0
U+0440 'р': 2
U+0438 'и': 4
U+0432 'в': 6
U+0435 'е': 8
U+0442 'т': 10Мы можем симулировать работу for range обычным циклом for и функцией DecodeRuneInString() из пакета unicode/utf8. Она декодирует первый код-поинт в строке и возвращает его вместе с количеством занимаемых байт:
str := "Привет"
for i := 0; i < len(str); {
runeVal, width := utf8.DecodeRuneInString(str[i:])
fmt.Printf("%#U: %d\n", runeVal, i)
i += width
}goВывод будет тот же самый.
Сколько символов в строке
len() вернет количество байт. Посчитать код-поинты можно функцией utf8.RuneCountInString() или конвертацией в слайс рун:
str := "Привет"
fmt.Println(utf8.RuneCountInString(str))
fmt.Println(len([]rune(str)))go6
6Сама по себе конвертация []rune(str) аллоцирует слайс и декодирует в него всю строку. Но запись len([]rune(str)) компилятор распознает и заменяет подсчетом без аллокации, так что по цене способы одинаковы. И все же для подсчета лучше RuneCountInString(): она прямо говорит, что делает, а оптимизация срабатывает только на этой конкретной записи.
Невалидный UTF-8
Напомним: строка – это просто байты, и они не обязаны быть валидным UTF-8. Например, строку можно собрать из произвольного слайса байт. Что сделает for range, наткнувшись на байт, с которого не начинается ни один валидный UTF-8 код-поинт?
str := string([]byte{193, 65})
for i, runeVal := range str {
fmt.Printf("%#U начинается с байта %d\n", runeVal, i)
}goU+FFFD '�' начинается с байта 0
U+0041 'A' начинается с байта 1Байт 193 (0xC1) не может начинать валидную UTF-8 последовательность. Вместо паники или ошибки for range подставляет специальный код-поинт U+FFFD – replacement character, знакомый многим значок �. Индекс при этом сдвигается на один байт, и декодирование продолжается.
Проверить строку на валидность можно функцией utf8.ValidString():
fmt.Println(utf8.ValidString("Привет"))
fmt.Println(utf8.ValidString(string([]byte{193, 65})))gotrue
falseЭто полезно на границах системы: если приложение принимает текст извне (из сети, из файла), не стоит верить, что там валидный UTF-8.
Срез посреди код-поинта
Невалидный UTF-8 легко получить и самому. Срез str[i:j] работает с байтовыми индексами, и ему все равно, где заканчивается код-поинт. Обрежем Привет до трех байт:
str := "Привет"
cut := str[:3]
fmt.Printf("%q\n", cut)
fmt.Println(utf8.ValidString(cut))go"П\xd1"
falseВ три байта поместилась буква П (D0 9F) и только первый байт буквы р (D1). Строка стала невалидной: %q показывает оборванный байт как \xd1, а в терминале на его месте появится �. Типичный источник такого бага – обрезка текста до лимита вида str[:maxLen]: на английском тексте все работает, а на русском строка ломается.
Резать нужно по границе код-поинта. Индексы, которые выдает for range, – как раз такие границы:
package iterate
func truncateRunes(str string, n int) string {
count := 0
for i := range str {
if count == n {
return str[:i]
}
count++
}
return str
}gofmt.Println(truncateRunes("Привет", 3))
fmt.Println(truncateRunes("Hi", 3))goПри
HiФункция возвращает подстроку оригинала и ничего не аллоцирует. Но руна – это еще не видимый символ: флаг из следующего раздела такая функция все равно может разрезать пополам.
Кластеры графем
Рассмотрим строку из одного видимого символа – флага Бразилии 🇧🇷. Пройдемся по ней через for range:
str := "🇧🇷"
for i, runeVal := range str {
fmt.Printf("%#U: %d\n", runeVal, i)
}goU+1F1E7 '🇧': 0
U+1F1F7 '🇷': 4Один видимый символ оказался двумя код-поинтами: флаги в Юникоде кодируются парой региональных индикаторов (B + R для Бразилии, A + Q для Антарктиды и так далее). Ни len(), ни RuneCountInString() не вернут ожидаемую единицу:
str := "🇧🇷"
fmt.Println(len(str))
fmt.Println(utf8.RuneCountInString(str))go8
2То, что человек воспринимает как один символ, называется кластером графем. Кластер может состоять из нескольких код-поинтов: таковы многие эмодзи (👨👩👧 – это целых 5 код-поинтов), символы тайского, деванагари и других письменностей, буквы с комбинируемыми диакритиками.
Стандартная библиотека Go не умеет считать графемы. Если вашему приложению это нужно – например, чтобы честно посчитать длину сообщения или обрезать текст, не разорвав эмодзи пополам – понадобится библиотека, реализующая правила сегментации из стандарта Юникода UAX #29 ↗, такая как rivo/uniseg ↗:
str := "🇧🇷"
fmt.Println(uniseg.GraphemeClusterCount(str))go1Итого у нас есть три способа посмотреть на одну и ту же строку:
| Уровень | Что это | Как посчитать | 🇧🇷 |
|---|---|---|---|
| Байты | Физическое содержимое строки | len(str) | 8 |
| Код-поинты (руны) | Единицы Юникода | utf8.RuneCountInString(str) | 2 |
| Кластеры графем | Видимые символы | uniseg.GraphemeClusterCount(str) | 1 |
Конвертации
Из int в string
Начнем с конвертации, которая регулярно удивляет новичков:
x := 1070
s := string(x)
fmt.Println(s) // ЮgoОжидание – строка "1070". Реальность – буква Ю. Конвертация string(x) интерпретирует число как код-поинт: 1070 в hex – это 0x42E, то есть код-поинт U+042E, буква Ю.
На такую конвертацию ругается go vet:
conversion from int to string yields a string of one rune, not a string of digitsЕсли вам нужен именно код-поинт, выразите намерение явно через rune – к такой записи go vet претензий не имеет:
str := string(rune(1070))
fmt.Println(str)goЮА если нужно число в строковом представлении – используйте strconv.Itoa():
str := strconv.Itoa(1070)
fmt.Println(str)go1070В обратную сторону конвертация int(str) не скомпилируется вовсе:
str := "1070"
fmt.Println(int(str)) // cannot convert str (variable of type string) to type intgoДля парсинга чисел из строк есть strconv.Atoi():
num, err := strconv.Atoi("1070")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(num)go1070string и []byte
Поскольку строка под капотом – массив байт, конвертации в []byte и обратно самые естественные:
fmt.Println([]byte("Hello"))go[72 101 108 108 111]fmt.Println(string([]byte{72, 101, 108, 108, 111}))
fmt.Printf("%q\n", string([]byte{193, 65}))goHello
"\xc1A"Заметьте: собрать строку можно из любых байт, в том числе из невалидного UTF-8 ("\xc1A") – конвертация ничего не проверяет и не исправляет.
string и []rune
Конвертация в []rune декодирует UTF-8 байты строки в слайс код-поинтов:
fmt.Println([]rune("Привет"))go[1055 1088 1080 1074 1077 1090]Это те же шесть код-поинтов, что на схеме выше, только в десятичной записи: 1055 – это 0x41F, то есть U+041F, буква П. Из 12 байт получилось 6 рун.
Обратная конвертация кодирует каждую руну обратно в UTF-8:
str := string([]rune{0x41F, 0x440, 0x438, 0x432, 0x435, 0x442})
fmt.Println(str)goПривет[]rune удобен, когда нужен доступ к символам по индексу: runes[2] – третий код-поинт, тогда как str[2] – третий байт. Но помните о цене: аллокация и полное перекодирование строки. И руна – это все еще код-поинт, а не видимый символ: для флага 🇧🇷 []rune вернет две руны, и runes[0] окажется лишь половиной флага.
Цена конвертаций
Конвертации []byte(str) и string(bytes) копируют все байты. Убедимся с помощью testing.AllocsPerRun() – эта функция запускает колбэк много раз и возвращает среднее количество аллокаций за запуск. Результаты конвертаций колбэки сохраняют в переменные на уровне пакета – так результат переживает колбэк, и компилятор не может оставить копию на стеке:
package convcost_test
import (
"bytes"
"fmt"
"strings"
"testing"
)
var (
sinkString string
sinkBytes []byte
sinkInt int
sinkBool bool
)
func Example_conversionAllocates() {
str := strings.Repeat("go", 32)
data := bytes.Repeat([]byte("go"), 32)
toBytes := testing.AllocsPerRun(100, func() {
sinkBytes = []byte(str)
})
toString := testing.AllocsPerRun(100, func() {
sinkString = string(data)
})
fmt.Println(toBytes, toString)
// Output:
// 1 1
}goПо одной аллокации на каждую конвертацию. Причина копирования – иммутабельность. Слайс байт можно изменять, строку – нельзя. Если бы string(data) разделяла память со слайсом data, то запись в data[0] меняла бы «иммутабельную» строку. Единственный способ сохранить гарантию – копия:
data := []byte("Hello")
str := string(data)
data[0] = 'Y'
fmt.Println(str)
fmt.Println(string(data))goHello
YelloСтрока str не изменилась – у нее свой массив байт, скопированный при конвертации.
Хорошая новость: компилятор умеет не копировать, когда копия не нужна. Классический пример – поиск в мапе по ключу-слайсу. Строка string(key) нужна только на время поиска, поэтому компилятор обходится без копии:
key := bytes.Repeat([]byte("k"), 64)
m := map[string]int{string(key): 42}
allocs := testing.AllocsPerRun(100, func() {
sinkInt = m[string(key)]
})
fmt.Println(allocs)go0Ноль аллокаций: мапа ищет по байтам слайса напрямую. То же самое со сравнением – string(data) == target сравнивает байты без создания строки:
data := bytes.Repeat([]byte("go"), 32)
target := strings.Repeat("go", 32)
allocs := testing.AllocsPerRun(100, func() {
sinkBool = string(data) == target
})
fmt.Println(allocs)go0И с итерацией for range []byte(str):
str := strings.Repeat("го", 32)
allocs := testing.AllocsPerRun(100, func() {
for _, b := range []byte(str) {
sinkInt += int(b)
}
})
fmt.Println(allocs)go0Но это не общее правило «временная конвертация бесплатна», а список конкретных конструкций, которые компилятор распознает. Передайте string(data) в функцию – и копия появится, хотя strings.Contains() строку никуда не сохраняет:
data := bytes.Repeat([]byte("go"), 32)
allocs := testing.AllocsPerRun(100, func() {
sinkBool = strings.Contains(string(data), "og")
})
fmt.Println(allocs)go1Здесь лучше сразу работать с байтами: в пакете bytes есть аналоги почти всех функций из strings, например bytes.Contains().
С []byte(str) компилятор умеет больше. Начиная с Go 1.22 он не копирует строку, даже если слайс сохранен в переменную, – при условии, что слайс не покидает функцию и никто его не изменяет. Тогда слайс просто смотрит на байты строки:
str := strings.Repeat("го", 32)
allocs := testing.AllocsPerRun(100, func() {
b := []byte(str)
sinkBool = utf8.Valid(b)
})
fmt.Println(allocs)go0Стоит записать в слайс хоть один байт – и копия возвращается:
str := strings.Repeat("го", 32)
allocs := testing.AllocsPerRun(100, func() {
b := []byte(str)
b[0] = 'G'
sinkBool = utf8.Valid(b)
})
fmt.Println(allocs)go1Эта оптимизация хрупкая: компилятор должен доказать, что слайс не изменяется. Если слайс уходит в функцию, тело которой компилятор не может проанализировать, копия вернется. Например, bytes.Count() внутри вызывает функцию на ассемблере, и bytes.Count([]byte(str), sep) копирует строку.
Вывод: бесплатны m[string(key)], string(b) == s, for range []byte(s) и []byte(s), который только читают внутри функции. В остальных типичных случаях – строку сохраняют или передают в функцию, слайс изменяют или он уходит из функции – байты копируются (проверялось на Go 1.27).
Сравнение строк
Строки в Go сравниваются операторами ==, !=, <, >, <=, >= – побайтово, в лексикографическом порядке:
fmt.Println("Go" == "Go")
fmt.Println("Go" == "go")
fmt.Println("apple" < "banana")
fmt.Println("Go" < "go")
fmt.Println("Zebra" < "apple")gotrue
false
true
true
true"Go" < "go", потому что байт G (0x47) меньше байта g (0x67): в ASCII все заглавные латинские буквы идут раньше строчных. Поэтому и "Zebra" < "apple". Сравнение регистрозависимое и ничего не знает про алфавиты – строки сравниваются байт за байтом до первого различия.
Из побайтового сравнения следует неочевидный факт: две строки, которые выглядят одинаково, могут быть не равны:
composed := "\u00E4"
decomposed := "a\u0308"
fmt.Println(composed == decomposed)
fmt.Printf("%+q: % x\n", composed, composed)
fmt.Printf("%+q: % x\n", decomposed, decomposed)gofalse
"\u00e4": c3 a4
"a\u0308": 61 cc 88Обе переменные на экране выглядят как ä. Но первая – это один код-поинт U+00E4, а вторая – буква a плюс комбинируемая диакритика U+0308. Байты разные – значит, строки не равны. Это проблема нормализации Юникода: прежде чем сравнивать текст из внешних источников, приведите его к одной нормальной форме. В Go для этого есть пакет golang.org/x/text/unicode/norm:
composed := norm.NFC.String("\u00E4")
decomposed := norm.NFC.String("a\u0308")
fmt.Println(composed == decomposed)
fmt.Printf("% x\n", composed)
fmt.Printf("% x\n", decomposed)gotrue
c3 a4
c3 a4NFC (Normalization Form C, от «composition») – нормальная форма, в которой все, что можно, склеено в один код-поинт. a + U+0308 превращается в U+00E4, и байты обеих строк совпадают. Есть и обратная форма, NFD, – она раскладывает ä на a и диакритику. Для хранения и сравнения текста обычно выбирают NFC.
Для сравнения без учета регистра есть strings.EqualFold():
fmt.Println(strings.EqualFold("Go", "GO"))
fmt.Println(strings.EqualFold("привет", "ПРИВЕТ"))gotrue
trueИдиома strings.ToLower(a) == strings.ToLower(b) дает похожий результат, но аллоцирует новые строки (когда в них есть что менять – иначе ToLower() возвращает оригинал). EqualFold() сравнивает руны на лету, без аллокаций. Для этого он использует кейс-фолдинг – приводит каждый символ к единой форме, в которой регистр не различается. Фолдинг бывает простым и полным: простой заменяет символ ровно одним символом, полный может заменить его несколькими. EqualFold() делает только простой, поэтому EqualFold("ß", "SS") вернет false: полный фолдинг превратил бы ß в ss, а простой не может сделать из одного символа два.
Эффективная конкатенация
Как склеить много строк в одну? Первое, что приходит в голову – оператор +:
package builder
func concatPlus(parts []string) string {
result := ""
for _, part := range parts {
result += part
}
return result
}goПроблема вновь в иммутабельности: строки нельзя изменить, поэтому каждая операция result += part создает новую строку и копирует в нее оба операнда. На тысяче частей это 999 аллокаций – все, кроме первой: пока result пуст, рантайм просто возвращает единственный непустой операнд. Объем копирования при этом растет квадратично.
Для таких задач в стандартной библиотеке есть strings.Builder – буфер, в который можно дописывать без пересоздания:
var b strings.Builder
b.WriteString("Hello")
b.WriteString(", ")
b.WriteString("world")
fmt.Println(b.String())
fmt.Println(b.Len())goHello, world
12Перепишем конкатенацию через Builder:
package builder
import "strings"
func concatBuilder(parts []string) string {
var b strings.Builder
for _, part := range parts {
b.WriteString(part)
}
return b.String()
}goСравним оба варианта бенчмарком на 1000 частей:
BenchmarkConcatPlus 196387 ns/op 1063876 B/op 999 allocs/op
BenchmarkConcatBuilder 3936 ns/op 5368 B/op 10 allocs/opBuilder быстрее в 50 раз: 10 аллокаций против 999, 5 килобайт против мегабайта. Внутри у него слайс байт, который растет амортизированно – как слайс при append.
Несколько практических советов:
-
Если суммарный размер частей известен заранее или его легко посчитать, вызовите
b.Grow(n)– буфер аллоцируется один раз, и от 10 аллокаций в бенчмарке останется одна. -
Если части уже лежат в слайсе,
strings.Join()читается проще и работает так же эффективно – внутри тот жеBuilderсGrow:
goparts := []string{"SELECT *", "FROM users", "WHERE id = 1"} fmt.Println(strings.Join(parts, " "))SELECT * FROM users WHERE id = 1 -
Склеить несколько строк одним выражением
a + b + c– нормально: сколько бы ни было операндов, это одна аллокация – рантайм заранее считает суммарную длину и выделяет память один раз. Квадратичная сложность появляется только при наращивании строки в цикле. -
fmt.Sprintf()удобен для форматирования, но это самый дорогой способ конкатенации: рефлексия, парсинг форматной строки, промежуточный буфер. Стоит избегать его в слишком высоконагруженных сценариях. Однако даже здесь лучше сначала сделать профилирование и удостовериться, что проблема действительно есть, чем заниматься преждевременной оптимизацией. -
Не копируйте
Builderпосле первой записи – передавайте его по указателю (*strings.Builder). Копия делила бы буфер с оригиналом и могла бы затереть байты строки, которую тот уже вернул, поэтому запись в такую копию вызывает панику.
Утечка памяти при взятии подстроки
Мы уже видели, что подстрока разделяет нижележащий массив с оригинальной строкой. Здесь кроется та же ловушка, что и в утечке капасити у слайсов.
Представим функцию, которая достает заголовки (все до пустой строки) из HTTP-ответа:
package leak
import "strings"
func findHeaders(data string) string {
index := strings.Index(data, "\r\n\r\n")
if index == -1 {
return ""
}
return data[:index]
}goПроверим, что подстрока действительно указывает на массив оригинальной строки:
data := "Content-Type: text/html\r\n\r\nHello, World!"
headers := findHeaders(data)
fmt.Println(headers)
fmt.Println("общий массив:", unsafe.StringData(headers) == unsafe.StringData(data))goContent-Type: text/html
общий массив: trueТеперь представим, что data – это ответ размером в 10 мегабайт, а заголовки занимают первые 200 байт. Возвращенная подстрока держит ссылку на весь десятимегабайтный массив. Пока жива подстрока – например, лежит в кеше или в долгоживущей структуре – Garbage Collector не может освободить ни байта из этих 10 мегабайт. Строка выглядит на 200 байт, а память держит на 10 мегабайт – это и есть утечка.
Решение – функция strings.Clone() (появилась в Go 1.18). Она копирует байты подстроки в новый массив, отвязывая ее от оригинала:
package safe
import "strings"
func findHeaders(data string) string {
index := strings.Index(data, "\r\n\r\n")
if index == -1 {
return ""
}
return strings.Clone(data[:index])
}godata := "Content-Type: text/html\r\n\r\nHello, World!"
headers := findHeaders(data)
fmt.Println(headers)
fmt.Println("общий массив:", unsafe.StringData(headers) == unsafe.StringData(data))goContent-Type: text/html
общий массив: falseТеперь у подстроки свой массив на 23 байта. Оригинальные 10 мегабайт сборщик мусора освободит, как только на data не останется ссылок.
Когда клонировать, а когда нет?
- Подстрока нужна только внутри функции и никуда не сохраняется – клонировать не нужно. Она не продлит жизнь оригиналу, а общий массив избавляет от лишнего копирования.
- Подстрока маленькая, оригинал большой, и подстрока переживет оригинал – клонируйте.
- Сомневаетесь – измерьте: профиль памяти (
pprof) покажет, какие аллокации держат хип.