Строки, руны и пакеты strings/strconv

Строки, байты и руны: модель UTF-8 в Go

Содержание курса

Преобразование string ↔ []byte и string ↔ []rune

Иногда нужно получить изменяемую копию строки — чтобы модифицировать отдельные байты или работать с рунами по индексу. Для этого есть два явных преобразования.

[]byte(s) создаёт срез, где каждый элемент — один байт исходной строки. []rune(s) декодирует строку как UTF-8 и возвращает срез кодовых точек — каждый элемент это rune.

package main

import "fmt"

func main() {
    s := "Привет"

    b := []byte(s)
    r := []rune(s)

    fmt.Println(len(s)) // 12 — байты
    fmt.Println(len(b)) // 12 — байты
    fmt.Println(len(r)) // 6  — руны
}

Почему len(r) == 6, а len(s) == 12? []rune(s) декодировал 12 байт в 6 кодовых точек. Каждый элемент среза — int32, и len считает элементы, а не байты.

Оба преобразования создают копию данных. Изменение среза не затрагивает исходную строку — это отдельная память.

Изменение через []byte: работает только с ASCII

Модифицировать []byte кириллической строки побайтово опасно. Буква «П» кодируется двумя байтами: D0 9F. Если заменить только первый байт на ASCII-символ, второй байт (9F) остаётся на месте как одиночный continuation byte — строка станет некорректным UTF-8:

b := []byte("Привет")
b[0] = 'X'  // заменяет D0 на X, оставляет 9F — битый UTF-8
fmt.Println(string(b)) // отображение зависит от среды; знак замены или мусор

Для демонстрации независимости копии безопаснее использовать ASCII-строку:

package main

import "fmt"

func main() {
    s := "Hello"
    b := []byte(s)
    b[0] = 'J'
    fmt.Println(s)         // Hello — не изменилась
    fmt.Println(string(b)) // Jello — только срез
}

Изменение символа через []rune — правильный способ

Если нужно заменить конкретный символ в кириллическом тексте, используйте []rune. Каждый элемент — целая кодовая точка, поэтому замена одного элемента корректна:

package main

import "fmt"

func main() {
    s := "Привет"
    r := []rune(s)
    r[0] = 'X'
    fmt.Println(string(r)) // Xривет — корректный результат
}

Обратные преобразования: string(b) строит строку из []byte, string(r) — из []rune, кодируя каждую руну в UTF-8.

Подсчёт рун через []rune

Длина среза []rune(s) — это число декодированных кодовых точек. Можно написать функцию подсчёта рун без импорта unicode/utf8:

func countRunes(s string) int {
    return len([]rune(s))
}

func main() {
    s := "Привет"
    fmt.Println(len(s))        // 12
    fmt.Println(countRunes(s)) // 6
}

len([]rune(s)) и utf8.RuneCountInString(s) дают одинаковый результат для любой строки — в том числе с некорректными байтами — потому что оба следуют одной семантике: каждый некорректный байт считается за одну руну (RuneError). Разницы нет ни для валидного UTF-8, ни для произвольных байтов.

Практическое правило: если нужно только посчитать руны, используйте utf8.RuneCountInString — он не выделяет срез. []rune(s) создавайте тогда, когда сам срез нужен для работы: доступа по индексу, изменения элементов или передачи в функцию.

Индексирование по []rune решает задачу, которую не решает s[i]: r[3] даст четвёртую кодовую точку строки, а s[3] — просто четвёртый байт, который в середине кириллической строки окажется продолжением многобайтовой последовательности, а не самостоятельным символом.