ЗАМЕР
bench/gostring/internals.go
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/interview/golang/strings-runes-bytes
Запись прогона
У этого замера записи прогона нет — только скрипт.
Скрипт
337 строк//go:build ignore
// Строки изнутри: что такое строка, что она удерживает и какие конвертации
// бесплатны.
//
// ЧЕТЫРЕ БЛОКА — четыре утверждения, каждое из которых проверяется, а не
// пересказывается:
//
// 1. строка — это заголовок из указателя и длины, и подстрока не копирует
// ничего;
// 2. ровно поэтому подстрока УДЕРЖИВАЕТ весь исходный массив. Это та же
// ловушка, что у срезов, и в текстовой обработке она встречается чаще;
// 3. часть конвертаций string↔[]byte компилятор делает бесплатно — их надо
// знать поимённо, иначе «конвертация всегда копирует» превращается в
// ненужный ручной обход;
// 4. склейка в цикле против strings.Builder — и цена, и число выделений.
//
// ЗАПУСК:
//
// go run bench/gostring/internals.go
package main
import (
"fmt"
"runtime"
"strings"
"testing"
"unicode/utf8"
"unsafe"
)
// Отрицательная дельта — не результат, а шум сборщика: после GC куча может
// оказаться на десятые доли мегабайта меньше исходной. Печатать «-0.0 МБ»
// значило бы предъявлять читателю разрешение прибора вместо числа.
func clamp(v float64) float64 {
if v < 0 {
return 0
}
return v
}
func heapMB() float64 {
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)
return float64(m.HeapAlloc) / (1 << 20)
}
// ------------------------------- 0. три уровня: байты, руны, графемы
// ТРИ УРОВНЯ, КОТОРЫЕ ПОСТОЯННО СМЕШИВАЮТ — и печатаются они первыми потому,
// что без этого различения весь остальной урок читается как набор исключений.
//
// Байт — единица хранения. Руна — кодовая точка Unicode. Графемный кластер —
// то, что человек называет символом. Эти три числа НЕ совпадают, и ни одно из
// них не является «правильным ответом на вопрос сколько символов»: правилен
// тот, который соответствует задаче.
//
// ПОЧЕМУ é ЗАПИСАН ЭКРАНИРОВАНИЕМ, А НЕ БУКВОЙ. Один и тот же видимый знак
// существует в двух формах: готовой (U+00E9) и составной (e + U+0301). Если
// написать его в исходнике буквой, форма будет зависеть от того, чем этот
// файл когда-то сохранили, — и замер начнёт печатать разное на разных
// машинах. Экранирование делает обе формы явными и воспроизводимыми.
func blockLevels() {
fmt.Println("ТРИ УРОВНЯ: БАЙТЫ, РУНЫ, ГРАФЕМЫ")
fmt.Println("────────────────────────────────")
fmt.Println(" строка байт рун видимых знаков")
rows := []struct {
label string
s string
visible int
}{
{`"A世🙂"`, "A\u4e16\U0001F642", 3},
{`"é" готовая форма`, "\u00e9", 1},
{`"é" составная форма`, "e\u0301", 1},
{`"👩👩👧" семья`, "\U0001F469\u200D\U0001F469\u200D\U0001F467", 1},
}
for _, r := range rows {
fmt.Printf(" %-28s %4d %5d %14d\n",
r.label, len(r.s), utf8.RuneCountInString(r.s), r.visible)
}
fmt.Println()
fmt.Println(" Две средние строки — один и тот же знак на экране и разные")
fmt.Println(" числа рун. Значит, руна НЕ равна символу, и счётчик рун на")
fmt.Println(" вопрос «сколько символов» отвечает верно только тогда, когда")
fmt.Println(" текст заведомо в готовой форме.")
fmt.Println()
fmt.Println(" Последняя строка доводит это до предела: восемнадцать байт,")
fmt.Println(" пять рун, один видимый знак. Пять рун — это три эмодзи и два")
fmt.Println(" соединителя нулевой ширины; разрезать такую последовательность")
fmt.Println(" по руне значит получить на экране трёх отдельных людей.")
fmt.Println()
fmt.Println(" Столбец видимых знаков посчитан вручную: разбиение на")
fmt.Println(" графемные кластеры стандартной библиотекой не делается вовсе.")
fmt.Println(" Это и есть содержательный ответ — «посчитать символы» в общем")
fmt.Println(" случае требует отдельного пакета, а не len и не []rune.")
}
// ------------------------------------- 0б. нарезка режет по байтам
// РЕЗАТЬ СТРОКУ МОЖНО ТОЛЬКО ПО БАЙТАМ, и это стоит увидеть, а не запомнить.
// Индексы среза строки — байтовые, поэтому s[:n] с произвольным n способно
// разрубить последовательность UTF-8 пополам. Результат не паникует и не
// ругается: получается строка, которая просто некорректна.
func blockSlicing() {
fmt.Println()
fmt.Println("НАРЕЗКА РЕЖЕТ ПО БАЙТАМ, А НЕ ПО СИМВОЛАМ")
fmt.Println("─────────────────────────────────────────")
s := "A\u4e16\U0001F642"
for _, n := range []int{1, 2, 4, 8} {
part := s[:n]
fmt.Printf(" s[:%d] %-14q корректный UTF-8: %-5v рун: %d\n",
n, part, utf8.ValidString(part), utf8.RuneCountInString(part))
}
fmt.Println()
fmt.Println(" Вторая строка — разрубленный иероглиф. Программа не упала и")
fmt.Println(" ничего не сообщила: строка в Go — последовательность")
fmt.Println(" ПРОИЗВОЛЬНЫХ байтов, и некорректный UTF-8 в ней законен.")
fmt.Println()
fmt.Println(" Обратите внимание на счётчик рун во второй строке: он")
fmt.Println(" насчитал руну там, где её нет. Обход подставляет вместо")
fmt.Println(" каждого некорректного байта символ замены U+FFFD — то есть")
fmt.Println(" повреждение превращается в вопросительный знак в ромбике, а")
fmt.Println(" не в ошибку.")
fmt.Println()
fmt.Println(" Практическое: обрезать текст по длине (например, до 100")
fmt.Println(" символов для превью) байтовым срезом нельзя. Резать надо по")
fmt.Println(" границам, которые даёт range, — он идёт ровно по началам рун.")
}
// ------------------------------------------------- 1. что такое строка
var (
sinkStr string
sinkBytes []byte
sinkRunes []rune
sinkBool bool
sinkInt int
)
func blockHeader() {
var s string
var b []byte
fmt.Println("ЧТО ТАКОЕ СТРОКА")
fmt.Println("────────────────")
fmt.Printf(" заголовок string %2d байт (указатель + длина)\n", unsafe.Sizeof(s))
fmt.Printf(" заголовок []byte %2d байт (указатель + длина + ёмкость)\n", unsafe.Sizeof(b))
fmt.Println()
long := strings.Repeat("абвгд", 200)
fmt.Printf(" подстрока long[10:20] выделений: %.0f\n",
testing.AllocsPerRun(100, func() { sinkStr = long[10:20] }))
fmt.Printf(" []byte(long) выделений: %.0f\n",
testing.AllocsPerRun(100, func() { sinkBytes = []byte(long) }))
fmt.Printf(" string(bytes) выделений: %.0f\n",
testing.AllocsPerRun(100, func() { sinkStr = string(sinkBytes) }))
fmt.Println()
fmt.Println(" Подстрока не копирует НИЧЕГО: она берёт указатель внутрь")
fmt.Println(" того же массива и свою длину. Отсюда и неизменяемость —")
fmt.Println(" запись в строку испортила бы все подстроки разом, поэтому")
fmt.Println(" её просто нет в языке: s[0] = 'x' не компилируется.")
fmt.Println()
fmt.Println(" А конвертация в []byte и обратно копирует, и это не")
fmt.Println(" недосмотр: срез изменяем, и без копии изменяемое и")
fmt.Println(" неизменяемое смотрели бы в одну память.")
}
// ------------------------------------- 2. подстрока удерживает весь массив
func blockRetained() {
fmt.Println()
fmt.Println("ЧТО УДЕРЖИВАЕТ КОРОТКАЯ ПОДСТРОКА ДЛИННОЙ СТРОКИ")
fmt.Println("────────────────────────────────────────────────")
base := heapMB()
// 8 МБ текста, из которого нужны первые десять байт.
huge := strings.Repeat("x", 8<<20)
full := heapMB()
// Держим ТОЛЬКО подстроку. Исходная строка недостижима.
part := huge[:10]
huge = ""
kept := heapMB()
// Та же подстрока, но скопированная.
copied := strings.Clone(part)
part = ""
after := heapMB()
fmt.Printf(" строка на 8 МБ создана %5.1f МБ\n", clamp(full-base))
fmt.Printf(" оставили подстроку из %d байт %5.1f МБ\n", len(copied), clamp(kept-base))
fmt.Printf(" после strings.Clone %5.1f МБ\n", clamp(after-base))
fmt.Println()
fmt.Println(" Десять байт удерживают восемь мегабайт. Причина та же,")
fmt.Println(" по которой подстрока бесплатна: она указывает ВНУТРЬ")
fmt.Println(" исходного массива, а сборщик собирает массив целиком или")
fmt.Println(" не собирает вовсе.")
fmt.Println()
fmt.Println(" Где это встречается: разобрали большой ответ, сохранили из")
fmt.Println(" него идентификатор — и ответ остался в памяти вместе с ним.")
fmt.Println(" Лечится strings.Clone: он копирует, и ссылка на большой")
fmt.Println(" массив пропадает.")
_ = copied
}
// -------------------------------------------- 3. бесплатные конвертации
func blockFreeConversions() {
fmt.Println()
fmt.Println("КАКИЕ КОНВЕРТАЦИИ НЕ ВЫДЕЛЯЮТ")
fmt.Println("──────────────────────────────")
m := map[string]int{"ключ": 1}
key := []byte("ключ")
str := "ключ"
fmt.Printf(" m[string(b)] выделений: %.0f\n",
testing.AllocsPerRun(100, func() { sinkInt = m[string(key)] }))
fmt.Printf(" string(b) == s выделений: %.0f\n",
testing.AllocsPerRun(100, func() { sinkBool = string(key) == str }))
fmt.Printf(" for range string(b) выделений: %.0f\n",
testing.AllocsPerRun(100, func() {
for range string(key) {
}
}))
fmt.Printf(" s := string(b), строка сохранена выделений: %.0f\n",
testing.AllocsPerRun(100, func() { sinkStr = string(key) }))
fmt.Println()
fmt.Println(" Три первые формы компилятор узнаёт и копию не строит:")
fmt.Println(" строка нужна только на время выражения и наружу не")
fmt.Println(" попадает. Четвёртая сохраняет строку — и копия обязательна.")
fmt.Println()
fmt.Println(" Практический вывод: считать ключ из []byte и искать в карте")
fmt.Println(" можно прямо, без ручных ухищрений и без unsafe. Правило")
fmt.Println(" «конвертация всегда копирует» верно только там, где")
fmt.Println(" результат переживает выражение.")
}
// ------------------------------------------------- 4. склейка в цикле
func concatPlus(n int) float64 {
r := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
s := ""
for j := 0; j < n; j++ {
s += "abcdefgh"
}
sinkStr = s
}
})
return float64(r.T.Nanoseconds()) / float64(r.N)
}
func concatBuilder(n int, grow bool) float64 {
r := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
if grow {
sb.Grow(n * 8)
}
for j := 0; j < n; j++ {
sb.WriteString("abcdefgh")
}
sinkStr = sb.String()
}
})
return float64(r.T.Nanoseconds()) / float64(r.N)
}
func blockConcat() {
fmt.Println()
fmt.Println("СКЛЕЙКА В ЦИКЛЕ: ПОЧЕМУ ЭТО НЕ ПРО СКОРОСТЬ ОПЕРАЦИИ")
fmt.Println("────────────────────────────────────────────────────")
fmt.Println(" кусков s += x Builder Builder+Grow s+=x / Builder")
for _, n := range []int{10, 100, 1000} {
p, b, g := concatPlus(n), concatBuilder(n, false), concatBuilder(n, true)
fmt.Printf(" %6d %9.0f нс %9.0f нс %9.0f нс %14.1f\n", n, p, b, g, p/b)
}
fmt.Println()
fmt.Printf(" выделений на 1000 кусков: s += x %5.0f Builder %3.0f Builder+Grow %3.0f\n",
testing.AllocsPerRun(3, func() {
s := ""
for j := 0; j < 1000; j++ {
s += "abcdefgh"
}
sinkStr = s
}),
testing.AllocsPerRun(3, func() {
var sb strings.Builder
for j := 0; j < 1000; j++ {
sb.WriteString("abcdefgh")
}
sinkStr = sb.String()
}),
testing.AllocsPerRun(3, func() {
var sb strings.Builder
sb.Grow(8000)
for j := 0; j < 1000; j++ {
sb.WriteString("abcdefgh")
}
sinkStr = sb.String()
}))
fmt.Println()
fmt.Println(" Смотреть надо не на кратность — она растёт с числом кусков и")
fmt.Println(" потому ничего не значит сама по себе, — а на её РОСТ. Строки")
fmt.Println(" неизменяемы, поэтому каждый += строит новую строку и копирует")
fmt.Println(" в неё всё накопленное: работа квадратична по числу кусков.")
fmt.Println()
fmt.Println(" Builder пишет в растущий буфер и отдаёт его строкой без")
fmt.Println(" копии — выделений у него единицы против тысячи. Grow убирает")
fmt.Println(" и их, когда итоговый размер известен заранее.")
fmt.Println()
fmt.Println(" Оговорка, без которой правило вредит: на двух-трёх кусках")
fmt.Println(" a + b + c быстрее Builder и читается лучше. Компилятор")
fmt.Println(" склеивает такое одним вызовом concatstrings.")
}
func main() {
fmt.Println("go1.24.7 linux/amd64 | строки изнутри")
fmt.Println()
blockLevels()
blockSlicing()
blockHeader()
blockRetained()
blockFreeConversions()
blockConcat()
_ = sinkRunes
}