Строка в 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 есть два типа, которые описывают «часть строки».
| Тип | Псевдоним для | Что хранит |
|---|---|---|
byte | uint8 | один байт (0–255) |
rune | int32 | один 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 413 | 1 063 868 | 999 |
strings.Builder | 5 800 | 5 368 | 10 |
Builder + Grow | 3 961 | 2 048 | 1 |
Разница принципиальная. Конкатенация выделила больше мегабайта памяти и сделала 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 байтов. Отсюда выводы:
lenи индексация работают с байтами; для символов используйтеfor range,[]runeиutf8.RuneCountInString.byte— этоuint8(сырой байт),rune— этоint32(символ Unicode); путаница между ними даёт «битые» символы на не-ASCII тексте.- Собирайте строки через
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 range — rune.Как перебрать строку по символам, а не по байтам?
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().Теги: