Работа со строками в Go: rune vs byte, strings.Builder и конвертации

Строка в Go — это неизменяемая последовательность байтов в кодировке UTF-8. Этот подход отличается от подхода языков C и Python, где используется массив символов (char). Из этого факта вытекают почти все вопросы новичков: почему len("привет") возвращает 12, а не 6, чем rune отличается от byte и почему конкатенация в цикле через += внезапно становится узким местом. Разберём по порядку с примерами и бенчмарками.

Это один из разборов в серии для начинающих. Общий маршрут изучения языка собран в статье «Go для начинающих: дорожная карта».

Что такое строка в Go

Внутри строка является структурой из двух полей: указателя на массив байтов и длины. Сам массив доступен только на чтение: изменить отдельный символ строки нельзя.

s := "go"
s[0] = 'G' // ошибка компиляции: cannot assign to s[0] (neither addressable nor a map index expression)

Строковые литералы хранятся в UTF-8. Это означает, что один символ может занимать от одного до четырёх байтов: латиница и ASCII — один байт, кириллица — два, многие эмодзи — четыре. Поэтому встроенная функция len возвращает количество байтов, а не символов:

fmt.Println(len("go"))     // 2
fmt.Println(len("привет")) // 12, а не 6
fmt.Println(len("🚀"))      // 4

Это первое, обо что спотыкаются новички. Функция len она про память, а не про то, сколько «букв» выводится на экране.

byte и rune

В Go есть два типа, которые описывают «часть строки».

ТипПсевдоним дляЧто хранит
byteuint8один байт (0–255)
runeint32один Unicode code point

byte является синонимом uint8, а rune — синоним int32. Названия введены, чтобы код читался по смыслу: byte говорит «это сырой байт», а rune — «это символ Unicode».

Когда вы обращаетесь к строке по индексу, вы получаете байт, а не символ:

s := "Go"
fmt.Println(s[0])         // 71  — это число (byte)
fmt.Printf("%c\n", s[0])  // G   — тот же байт как символ

Для строки из ASCII «один индекс = один символ» работает. Но стоит появиться кириллице, и индексация по байтам начнёт резать символы пополам:

s := "Да"
fmt.Println(len(s)) // 4: 'Д' и 'а' занимают по 2 байта
fmt.Printf("%c\n", s[0]) // Ð — половинка буквы «Д», мусор

Чтобы работать именно с символами, строку нужно рассматривать как последовательность рун.

Итерация: range против индекса

Цикл for range по строке устроен особенным образом: он декодирует UTF-8 и на каждой итерации отдаёт руну и её байтовую позицию (а не порядковый номер символа).

s := "Да!"
for i, r := range s {
    fmt.Printf("позиция %d: %c (код %d)\n", i, r, r)
}
// позиция 0: Д (код 1044)
// позиция 2: а (код 1072)
// позиция 4: ! (код 33)

Обратите внимание: индексы идут 0, 2, 4 — потому что i это смещение в байтах, а буквы «Д» и «а» заняли по два байта. Если же пройтись по строке классическим индексным циклом, вы получите байты:

s := "Да!"
for i := 0; i < len(s); i++ {
    fmt.Printf("%d ", s[i]) // 208 148 208 176 33 — сырые байты UTF-8
}

Если вам нужны символы, то используйте for range или []rune. Если нужны байты (например, для бинарной обработки), то используйте индексный цикл.

Подсчёт символов

Для подсчёта символов, а не байт, есть utf8.RuneCountInString:

import "unicode/utf8"

s := "привет"
fmt.Println(len(s))                    // 12 байтов
fmt.Println(utf8.RuneCountInString(s)) // 6 символов

То же значение даст len([]rune(s)), но RuneCountInString не выделяет память под промежуточный слайс и потому предпочтительнее.

Конвертации между строкой, []byte и []rune

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

s := "Да"

b := []byte(s)   // [208 148 208 176] — сырые байты UTF-8
r := []rune(s)   // [1044 1072]       — code points

fmt.Println(string(b)) // Да — обратно из байтов
fmt.Println(string(r)) // Да — обратно из рун
fmt.Println(len(b), len(r)) // 4 2

[]rune(s) удобен, когда нужен доступ к символам по индексу или их подсчёт. []byte(s) для передачи строк в функции, работающие с байтами (io.Writer, хеши, шифрование).

Ещё одна ловушка языка: string() от числа. Эта конструкция интерпретирует число как Unicode code point, а не переводит его в текст:

n := 65
fmt.Println(string(rune(n))) // "A" — символ с кодом 65
// strconv.Itoa(n) даст "65" — а вот это уже число как текст

go vet ловит самый частый вариант этой ошибки:

$ go vet ./...
./main.go:9:9: conversion from int to string yields a string of one rune, not a string of digits

Конвертация чисел и строк

Для перевода чисел в строки и обратно используют пакет strconv. Преобразованию int → string со всеми способами и бенчмарками посвящена отдельная статья — «Преобразование int в string в Go». Здесь кратко соберём обе стороны.

Число в строку:

import "strconv"

strconv.Itoa(42)              // "42"   — самый прямой способ для int
strconv.FormatInt(42, 10)     // "42"   — для int64, основание 10
strconv.FormatFloat(3.14, 'f', 2, 64) // "3.14"

Строку в число:

n, err := strconv.Atoi("42")            // 42, nil
i, err := strconv.ParseInt("ff", 16, 64) // 255 — основание 16
f, err := strconv.ParseFloat("3.14", 64) // 3.14
b, err := strconv.ParseBool("true")      // true

Главное отличие от перевода в строку: парсинг может вернуть ошибку, и её нельзя игнорировать. На некорректном вводе вы получите нулевое значение и заполненный err:

n, err := strconv.Atoi("12abc")
fmt.Println(n, err)
// 0 strconv.Atoi: parsing "12abc": invalid syntax

Сборка строк: strings.Builder

Частая ошибка при работе со строками в сборе длинных строк через конкатенацию в цикле:

var s string
for i := 0; i < 1000; i++ {
    s += "go" // на каждой итерации создаётся новая строка
}

Поскольку строки неизменяемы, то каждая операция += выделяет новый массив байтов и копирует туда всё, что было накоплено раньше. Для n шагов это превращается в квадратичную сложность по объёму копирования.

Для такой задачи правильным инструментом выступает strings.Builder. Он накапливает данные в одном растущем буфере и отдаёт готовую строку в конце, без избыточного выделения и копирования памяти:

import "strings"

var sb strings.Builder
for i := 0; i < 1000; i++ {
    sb.WriteString("go")
}
result := sb.String()

У Builder есть методы WriteString, WriteByte, WriteRune и Write (для []byte). Все они не возвращают ошибку записи, на которую нужно реагировать (она всегда nil). Если заранее известен примерный размер результата, вызов Grow выделит буфер сразу и уберёт промежуточные реаллокации:

var sb strings.Builder
sb.Grow(2000) // зарезервировать 2000 байт
for i := 0; i < 1000; i++ {
    sb.WriteString("go")
}

Бенчмарк

Сравним три подхода на сборке строки из 1000 фрагментов по два символа (Go 1.24, AMD Ryzen 5 PRO 4650U):

const n = 1000
const word = "go"

func BenchmarkConcat(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var s string
        for j := 0; j < n; j++ {
            s += word
        }
        _ = s
    }
}

func BenchmarkBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for j := 0; j < n; j++ {
            sb.WriteString(word)
        }
        _ = sb.String()
    }
}

func BenchmarkBuilderGrow(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        sb.Grow(n * len(word))
        for j := 0; j < n; j++ {
            sb.WriteString(word)
        }
        _ = sb.String()
    }
}
$ go test -bench=. -benchmem -benchtime=20000x .
BenchmarkConcat-12         20000    429413 ns/op   1063868 B/op   999 allocs/op
BenchmarkBuilder-12        20000      5800 ns/op      5368 B/op    10 allocs/op
BenchmarkBuilderGrow-12    20000      3961 ns/op      2048 B/op     1 allocs/op
ПодходВремя (ns/op)Память (B/op)Аллокации
+=429 4131 063 868999
strings.Builder5 8005 36810
Builder + Grow3 9612 0481

Разница принципиальная. Конкатенация выделила больше мегабайта памяти и сделала 999 аллокаций, тогда как Builder с предварительным Grow обошёлся одной аллокацией и в ~100 раз быстрее. Десять аллокаций у Builder без Grow из-за удвоения внутреннего буфера по мере роста. А явный Grow в предыдущем сводит их к одной.

Когда какой инструмент

ЗадачаИнструмент
Соединить две-три строкиоператор +
Объединить готовый слайс строкstrings.Join(parts, sep)
Собирать строку в циклеstrings.Builder
Нужен и io.Writer, и чтение байтовbytes.Buffer

strings.Join для уже готового слайса не уступает Builder и читается короче, поэтому если фрагменты уже лежат в слайсе, берите его:

parts := []string{"a", "b", "c"}
fmt.Println(strings.Join(parts, ", ")) // a, b, c

Полезные функции пакета strings

Большинство повседневных операций уже есть в стандартной библиотеке, своё писать не нужно:

strings.Contains("seafood", "foo")   // true
strings.HasPrefix("golang", "go")    // true
strings.HasSuffix("image.png", ".png") // true
strings.Split("a,b,c", ",")          // ["a" "b" "c"]
strings.Replace("oink", "k", "ky", -1) // "oinky"
strings.ToUpper("go")                // "GO"
strings.TrimSpace("  go  ")          // "go"
strings.Fields("  a  b   c ")        // ["a" "b" "c"]

strings.Fields малоизвестен, но особенно удобен. Он разбивает строку по любым пробельным символам (например: пробелы, табы) и сам отбрасывает пустые элементы.

Типичные ошибки

Индексация строки с не-ASCII символами

s := "café"
fmt.Println(s[3]) // 195 — байт, а не символ 'é'

Для доступа к символу по порядковому номеру конвертируйте в []rune:

r := []rune("café")
fmt.Printf("%c\n", r[3]) // é

Конкатенация в цикле

Любой цикл, где строка растёт через +=, то это сразу кандидат на замену strings.Builder. На коротких строках разница мизерна, а вот в горячем коде или на больших объёмах она выливается в лишние мегабайты аллокаций (см. бенчмарк выше).

Копирование Builder

strings.Builder нельзя копировать после первого использования. Внутри он хранит указатель на себя для контроля. Передавайте его по указателю:

func build(sb *strings.Builder) {
    sb.WriteString("go")
}

Если использовать скопированный непустой Builder, то программа упадёт с паникой strings: illegal use of non-zero Builder copied by value, т.к. проверка делается в рантайме, go vet такие копии не ловит.

string([]byte) в горячем цикле

Каждое преобразование string(b) и []byte(s) копирует данные. Обычно это незаметно, но в нагруженном коде стоит держать в голове: если можно работать с []byte без перевода в строку то лучше так и сделать.

Заключение

Вся работа со строками в Go строится на одном факте: строка = неизменяемая последовательность UTF-8 байтов. Отсюда выводы:

  1. len и индексация работают с байтами; для символов используйте for range, []rune и utf8.RuneCountInString.
  2. byte — это uint8 (сырой байт), rune — это int32 (символ Unicode); путаница между ними даёт «битые» символы на не-ASCII тексте.
  3. Собирайте строки через strings.Builder (или strings.Join для готового слайса), а не конкатенацией в цикле иначе потеряете в производительности на два порядка (см. бенчмарк выше).

Для конвертации чисел и строк есть пакет strconv: Itoa/Atoi для int, семейство Format*/Parse* для остальных типов. Полный разбор перевода int → string с бенчмарками в отдельной статье. Следующие шаги в изучении языка есть в дорожной карте для начинающих.

FAQ

Почему len для русского текста больше, чем количество букв?
len возвращает число байтов, а строки в Go хранятся в UTF-8, где кириллический символ занимает 2 байта. Чтобы посчитать символы, используйте utf8.RuneCountInString(s) или len([]rune(s)).
Чем rune отличается от byte?
byte — псевдоним uint8, хранит один сырой байт. rune — псевдоним int32, хранит один Unicode code point (символ). Индексация строки s[i] даёт byte, а итерация for rangerune.
Как перебрать строку по символам, а не по байтам?
Используйте for i, r := range s — на каждой итерации r будет руной (символом), а i — её байтовой позицией. Либо конвертируйте строку в []rune(s) для доступа по индексу.
Когда использовать strings.Builder, а когда bytes.Buffer?
strings.Builder оптимизирован для сборки именно строк и отдаёт результат через String() без лишнего копирования. bytes.Buffer берите, если буфер нужен ещё и как io.Writer или из него нужно читать байты.
Почему string(65) возвращает “A”, а не “65”?
Конструкция string(число) интерпретирует число как Unicode code point. Для перевода числа в его текстовое представление используйте strconv.Itoa(65), который вернёт "65". go vet предупреждает о таком использовании string().

Теги: