BackendBit

К статьям о 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()
}
go
str := "Hello"

inspectString(str)
go
addr: 0x1c985e6
len: 5

У зеро вэлью строки ("") указатель равен nil, а длина – нулю:

var str string

inspectString(str)
go
addr: <nil>
len: 0

Строго говоря, nil-указатель – деталь реализации рантайма gc: документация unsafe.StringData говорит лишь, что для пустой строки результат не определен и может быть nil.

Копирование строки

При копировании строки копируется только хедер – два машинных слова. Нижележащий массив не копируется:

str := "Hello"
strCopy := str

inspectString(str)
inspectString(strCopy)
go
addr: 0x1c985e6
len: 5

addr: 0x1c985e6
len: 5

Обе строки указывают на один и тот же массив байт. Поэтому копирование строки – дешевая операция: неважно, 5 байт в строке или 5 мегабайт, копируются всегда только указатель и длина.

Строки str и strCopy указывают на один массив байтДва строковых хедера с одинаковыми полями addr и len. Оба указывают на один и тот же нижележащий массив из пяти байт Hello. При копировании строки копируется только хедер.общий нижележащий массив straddr0x1c985e6len5strCopyaddr0x1c985e6len5 H e l l o

Подстрока

Взятие подстроки устроено так же, как слайс от слайса: новый хедер указывает на тот же нижележащий массив, но с другим смещением и длиной:

str := "Hello, world"
sub := str[7:]

inspectString(str)
inspectString(sub)

addr := unsafe.Add(unsafe.Pointer(unsafe.StringData(str)), 7)
fmt.Println("Адрес 8-го байта оригинальной строки:", addr)
go
addr: 0x1c9941a
len: 12

addr: 0x1c99421
len: 5

Адрес 8-го байта оригинальной строки: 0x1c99421

Подстрока sub указывает внутрь массива строки str. Никакого копирования байт не происходит – операция str[7:] выполняется за константное время. У этой эффективности есть и обратная сторона, о ней поговорим в конце статьи.

Подстрока sub указывает внутрь массива строки strНижележащий массив из двенадцати байт Hello, world. Хедер str указывает на байт с индексом 0 и имеет длину 12. Хедер sub указывает на байт с индексом 7 – букву w – и имеет длину 5. Байты world доступны обеим строкам, байты Hello, с пробелом – только str.общий нижележащий массив Доступно str и sub straddr0x1c9941alen12 H e l l o , subaddr0x1c99421len5 w o r l dДоступно только strДоступно str и sub

Иммутабельность

Одно из важных свойств строк в Go: строка иммутабельна, то есть неизменяема. Байты строки нельзя поменять после ее создания. Такой код не скомпилируется:

str := "Hello"
str[0] = 'Y'
go
cannot 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])
}
go
48 65 6C 6C 6F
Строка Hello – пять байт в шестнадцатеричном видеПять ячеек с байтами 48, 65, 6C, 6C, 6F. Над каждой ячейкой – буква (H, e, l, l, o), под каждой – индекс байта от 0 до 4. Одна буква занимает один байт.Hello 48 65 6C 6C 6F01234

Байт 48 – это код латинской буквы H в ASCII, 65 – буквы e, и так далее. Для английского текста все ожидаемо: одна буква – один байт.

Теперь попробуем строку на русском языке:

str := "Привет"

for i := 0; i < len(str); i++ {
	fmt.Printf("%X ", str[i])
}
go
D0 9F D1 80 D0 B8 D0 B2 D0 B5 D1 82
Строка Привет – двенадцать байт в шестнадцатеричном видеДвенадцать ячеек с байтами D0, 9F, D1, 80, D0, B8, D0, B2, D0, B5, D1, 82 и индексами от 0 до 11. Шесть букв занимают двенадцать байт. D0 9F D1 80 D0 B8 D0 B2 D0 B5 D1 8201234567891011

В строке из 6 букв оказалось 12 байт. Функция len() подтверждает:

fmt.Println(len("Hello"))
fmt.Println(len("Привет"))
go
5
12

Почему так? Текст в строке хранится в кодировке UTF-8. В Юникоде каждому символу соответствует код-поинт – число от 0 до 0x10FFFF. UTF-8 кодирует один код-поинт переменным количеством байт: от одного до четырех. Латинские буквы из ASCII занимают один байт, кириллица – два.

В нашей строке 6 код-поинтов по 2 байта каждый:

Строка Привет: шесть код-поинтов по два байта UTF-8Шесть колонок, по одной на букву слова Привет. В каждой колонке: буква, ее код-поинт (U+041F, U+0440, U+0438, U+0432, U+0435, U+0442) и два байта UTF-8. Всего двенадцать байт.буквы (6)код-поинты (6)байты UTF-8 (12) П р и в е тU+041F D0 9FU+0440 D1 80U+0438 D0 B8U+0432 D0 B2U+0435 D0 B5U+0442 D1 82

len() возвращает именно количество байт, и работает за O(1) – длина лежит прямо в хедере строки.

Индексация тоже работает на уровне байт. Выражение str[i] возвращает i-й байт, а не i-й символ:

str := "Hello"

fmt.Println(str[0])
fmt.Printf("%T\n", str[0])
go
72
uint8

72 – это десятичное значение байта 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")
go
Hello
Привет

Итерация по строке

Мы выяснили, что str[i] возвращает байт. А что вернет for range? Проверим на строке Привет:

str := "Привет"

for i, v := range str {
	fmt.Printf("i: %d, v: %v, v type: %T\n", i, v, v)
}
go
i: 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)
}
go
U+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)))
go
6
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)
}
go
U+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})))
go
true
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
}
go
fmt.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)
}
go
U+1F1E7 '🇧': 0
U+1F1F7 '🇷': 4

Один видимый символ оказался двумя код-поинтами: флаги в Юникоде кодируются парой региональных индикаторов (B + R для Бразилии, A + Q для Антарктиды и так далее). Ни len(), ни RuneCountInString() не вернут ожидаемую единицу:

str := "🇧🇷"

fmt.Println(len(str))
fmt.Println(utf8.RuneCountInString(str))
go
8
2

То, что человек воспринимает как один символ, называется кластером графем. Кластер может состоять из нескольких код-поинтов: таковы многие эмодзи (👨‍👩‍👧 – это целых 5 код-поинтов), символы тайского, деванагари и других письменностей, буквы с комбинируемыми диакритиками.

Стандартная библиотека Go не умеет считать графемы. Если вашему приложению это нужно – например, чтобы честно посчитать длину сообщения или обрезать текст, не разорвав эмодзи пополам – понадобится библиотека, реализующая правила сегментации из стандарта Юникода UAX #29 ↗, такая как rivo/uniseg ↗:

str := "🇧🇷"

fmt.Println(uniseg.GraphemeClusterCount(str))
go
1

Итого у нас есть три способа посмотреть на одну и ту же строку:

УровеньЧто этоКак посчитать🇧🇷
БайтыФизическое содержимое строки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)
go
1070

В обратную сторону конвертация int(str) не скомпилируется вовсе:

str := "1070"

fmt.Println(int(str)) // cannot convert str (variable of type string) to type int
go

Для парсинга чисел из строк есть strconv.Atoi():

num, err := strconv.Atoi("1070")
if err != nil {
	fmt.Println(err)
	return
}

fmt.Println(num)
go
1070

string и []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}))
go
Hello
"\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))
go
Hello
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)
go
0

Ноль аллокаций: мапа ищет по байтам слайса напрямую. То же самое со сравнением – 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)
go
0

И с итерацией for range []byte(str):

str := strings.Repeat("го", 32)

allocs := testing.AllocsPerRun(100, func() {
	for _, b := range []byte(str) {
		sinkInt += int(b)
	}
})

fmt.Println(allocs)
go
0

Но это не общее правило «временная конвертация бесплатна», а список конкретных конструкций, которые компилятор распознает. Передайте string(data) в функцию – и копия появится, хотя strings.Contains() строку никуда не сохраняет:

data := bytes.Repeat([]byte("go"), 32)

allocs := testing.AllocsPerRun(100, func() {
	sinkBool = strings.Contains(string(data), "og")
})

fmt.Println(allocs)
go
1

Здесь лучше сразу работать с байтами: в пакете 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)
go
0

Стоит записать в слайс хоть один байт – и копия возвращается:

str := strings.Repeat("го", 32)

allocs := testing.AllocsPerRun(100, func() {
	b := []byte(str)
	b[0] = 'G'
	sinkBool = utf8.Valid(b)
})

fmt.Println(allocs)
go
1

Эта оптимизация хрупкая: компилятор должен доказать, что слайс не изменяется. Если слайс уходит в функцию, тело которой компилятор не может проанализировать, копия вернется. Например, 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")
go
true
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)
go
false
"\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)
go
true
c3 a4
c3 a4

NFC (Normalization Form C, от «composition») – нормальная форма, в которой все, что можно, склеено в один код-поинт. a + U+0308 превращается в U+00E4, и байты обеих строк совпадают. Есть и обратная форма, NFD, – она раскладывает ä на a и диакритику. Для хранения и сравнения текста обычно выбирают NFC.

Для сравнения без учета регистра есть strings.EqualFold():

fmt.Println(strings.EqualFold("Go", "GO"))
fmt.Println(strings.EqualFold("привет", "ПРИВЕТ"))
go
true
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())
go
Hello, 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/op

Builder быстрее в 50 раз: 10 аллокаций против 999, 5 килобайт против мегабайта. Внутри у него слайс байт, который растет амортизированно – как слайс при append.

Несколько практических советов:

  • Если суммарный размер частей известен заранее или его легко посчитать, вызовите b.Grow(n) – буфер аллоцируется один раз, и от 10 аллокаций в бенчмарке останется одна.

  • Если части уже лежат в слайсе, strings.Join() читается проще и работает так же эффективно – внутри тот же Builder с Grow:

    parts := []string{"SELECT *", "FROM users", "WHERE id = 1"}
    
    fmt.Println(strings.Join(parts, " "))
    go
    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))
go
Content-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])
}
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))
go
Content-Type: text/html
общий массив: false

Теперь у подстроки свой массив на 23 байта. Оригинальные 10 мегабайт сборщик мусора освободит, как только на data не останется ссылок.

Когда клонировать, а когда нет?

  • Подстрока нужна только внутри функции и никуда не сохраняется – клонировать не нужно. Она не продлит жизнь оригиналу, а общий массив избавляет от лишнего копирования.
  • Подстрока маленькая, оригинал большой, и подстрока переживет оригинал – клонируйте.
  • Сомневаетесь – измерьте: профиль памяти (pprof) покажет, какие аллокации держат хип.