MEASUREMENT
bench/gostring/practice.go
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
This measurement has no recorded run — only the script.
Script
173 lines//go:build ignore
// Две задачи урока про строки, руны и байты: обе отвечают прогоном.
//
// ЗАДАЧА 1 — ПРЕДСКАЗАТЬ ВЫВОД. Всё вокруг одной путаницы: строка — это
// последовательность БАЙТОВ, а не символов. len даёт байты; индексация даёт
// байт; range даёт руны и байтовые смещения (поэтому индексы идут не подряд);
// []rune даёт руны. Разные числа на одной и той же строке.
//
// Строка «A世🙂» выбрана так, чтобы покрыть все три длины UTF-8 сразу: 1, 3
// и 4 байта. На чисто латинской строке ни одна ошибка темы не проявляется
// вовсе — и ровно поэтому живёт в коде до первого нелатинского ввода.
//
// Последняя строка — отдельная ловушка. string(s[0]) НЕ декодирует байт: это
// конвертация ЧИСЛА в руну с таким кодом. Для 230 получается «æ» длиной два
// байта — символ, не имеющий к исходной строке никакого отношения. Байтовый
// срез s[:1] дал бы другое неверное: один байт некорректного UTF-8.
//
// ЗАДАЧА 2 — ОЦЕНИТЬ КРАТНОСТЬ. Во сколько раз дороже посчитать руны через
// материализованный срез `r := []rune(s)`, чем через utf8.RuneCountInString.
// Работа одна — узнать число рун; отличается то, что первый способ ВЫДЕЛЯЕТ
// срез под все руны, а второй идёт по строке и не выделяет ничего.
//
// ПОЧЕМУ ИМЕННО МАТЕРИАЛИЗОВАННЫЙ СРЕЗ, А НЕ len([]rune(s)). Первая редакция
// сравнивала ровно то, что советуют во всех статьях, — `len([]rune(s))`
// против RuneCountInString, — и получила кратность 1,06, то есть НИКАКОЙ
// разницы. Проверка выделений объяснила почему: у `len([]rune(s))` ноль
// выделений. Компилятор узнаёт это выражение и считает руны на месте, не
// строя срез; советы, повторяющие «замените len([]rune(s)) на
// RuneCountInString», описывают компилятор, которого давно нет.
//
// Разница появляется там, где срез действительно НУЖЕН — когда его сохраняют
// и индексируют. Тогда выделение настоящее (проверено: 1), и его цена и есть
// содержательный ответ. Публиковать первую редакцию было бы враньём, а
// выбросить её молча — потерей самого интересного в теме, поэтому оба числа
// печатаются ниже.
//
// ПОЧЕМУ НЕ ВЗЯТА КОНКАТЕНАЦИЯ В ЦИКЛЕ. Кратность `s += x` против
// strings.Builder уходит в сотни раз, но квадратично зависит от числа витков:
// такое число измеряет длину цикла, а не разницу приёмов.
//
// ВЫВОД НАМЕРЕННО БЕЗ ПОДПИСЕЙ: те же строки печатаются в русской и английской
// версиях урока, поэтому в них не должно быть слов ни одного языка.
//
// ЗАПУСК:
//
// go run bench/gostring/practice.go
package main
import (
"fmt"
"strconv"
"strings"
"testing"
"unicode/utf8"
)
// ---------------------------------------------------------------- задача 1
func task1() {
// «A世🙂» — три знака и восемь байт: латинская буква занимает один,
// иероглиф три, эмодзи четыре. Одна строка покрывает все три случая
// UTF-8, и потому проходит через весь урок.
//
// ПОЧЕМУ ПЕЧАТАЮТСЯ ОБА БАЙТА, s[0] И s[1]. Первый — полноценный
// символ сам по себе, второй — начало трёхбайтовой последовательности.
// Пара показывает, что «взять первый байт» иногда случайно работает, и
// именно поэтому ошибка живёт в коде: на латинице она не проявляется.
//
// КОММЕНТАРИЕВ ВНУТРИ БЛОКА НЕТ НАМЕРЕННО: эти строки целиком
// переносятся в оба перевода урока, поэтому в них не должно быть слов
// ни одного языка.
s := "A世🙂"
fmt.Println(len(s))
fmt.Println(utf8.RuneCountInString(s))
fmt.Println(s[0])
fmt.Println(s[1])
fmt.Println(len([]rune(s)))
var offsets []string
for i := range s {
offsets = append(offsets, strconv.Itoa(i))
}
fmt.Println(strings.Join(offsets, " "))
fmt.Println(len(string(s[0])))
fmt.Println(len(string(s[1])))
}
// ---------------------------------------------------------------- задача 2
// Строка нарочно смешанная: латиница по одному байту, кириллица по два.
// На чистой латинице разрыв был бы меньше — и урок получил бы число,
// не переносимое на реальные тексты.
var text = "Ёжик в тумане шёл и считал руны: one, two, three, four, five."
func runeCount() float64 {
r := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = utf8.RuneCountInString(text)
}
})
return float64(r.T.Nanoseconds()) / float64(r.N)
}
// sink нужен, чтобы срез не остался локальным: иначе escape-анализ разместит
// его на стеке и замер покажет цену, которой в реальном коде не будет.
var sink []rune
func viaKeptSlice() float64 {
r := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
sink = []rune(text)
}
})
return float64(r.T.Nanoseconds()) / float64(r.N)
}
// Та самая форма из советов: компилятор узнаёт её и срез не строит.
func viaLenExpr() float64 {
r := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = len([]rune(text))
}
})
return float64(r.T.Nanoseconds()) / float64(r.N)
}
func task2() {
// Чередующиеся круги: если машина в середине замера занялась чем-то
// своим, это ударит по всем трём замерам, а не по одному.
countBest, keptBest, lenBest := runeCount(), viaKeptSlice(), viaLenExpr()
for i := 1; i < 7; i++ {
if v := runeCount(); v < countBest {
countBest = v
}
if v := viaKeptSlice(); v < keptBest {
keptBest = v
}
if v := viaLenExpr(); v < lenBest {
lenBest = v
}
}
ratio := keptBest / countBest
fmt.Printf("\n utf8.RuneCountInString(s) %6.2f нс выделений: %.0f\n",
countBest, testing.AllocsPerRun(100, func() { _ = utf8.RuneCountInString(text) }))
fmt.Printf(" len([]rune(s)) %6.2f нс выделений: %.0f\n",
lenBest, testing.AllocsPerRun(100, func() { _ = len([]rune(text)) }))
fmt.Printf(" r := []rune(s), срез сохранён %6.2f нс выделений: %.0f\n",
keptBest, testing.AllocsPerRun(100, func() { sink = []rune(text) }))
fmt.Printf("\n кратность (сохранённый срез / RuneCountInString) %6.2f\n", ratio)
fmt.Printf(" она же округлённо %6.1f\n", ratio)
fmt.Printf("\n Лучший из 7 чередующихся кругов. go1.24.7 linux/amd64\n")
fmt.Printf("\n Строка — %d байт, %d рун.\n", len(text), utf8.RuneCountInString(text))
fmt.Printf("\n Средняя строка — та самая, которую советуют заменить на\n")
fmt.Printf(" RuneCountInString. Замер совета не подтверждает: у неё ноль\n")
fmt.Printf(" выделений и то же время. Компилятор узнаёт выражение\n")
fmt.Printf(" len([]rune(s)) и считает руны на месте, среза не строя.\n")
fmt.Printf("\n Разница появляется там, где срез НУЖЕН — когда его сохраняют\n")
fmt.Printf(" и индексируют. Там выделение настоящее, и вот его цена.\n")
}
func main() {
fmt.Println("ЗАДАЧА 1 — предсказать вывод")
fmt.Println()
task1()
fmt.Println()
fmt.Println("ЗАДАЧА 2 — во сколько раз дороже сохранённый срез рун")
task2()
}