Урок курса

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

Golang с нуля

В предыдущих уроках вы работали со срезами []T и картами map[K]V. Строки в Go выглядят привычно, но устроены иначе — и это несоответствие между внешней простотой и внутренней моделью легко упустить. Разберём, что на самом деле хранит тип string.

string как последовательность байт: len, s[i] и отсутствие гарантии UTF-8

Тип string в Go — это неизменяемая последовательность байт. Не символов, не рун, не кодовых точек Unicode — именно байт, от 0 до 255 каждый.

Посмотрим, что происходит с кириллической строкой:

package main

import "fmt"

func main() {
    s := "Привет"
    fmt.Println(len(s))  // 12
    fmt.Println(s[0])    // 208  (первый байт буквы 'П' в UTF-8)
    fmt.Printf("%T\n", s[0]) // uint8
}

len(s) вернул 12, хотя в слове «Привет» шесть букв. Причина: каждая из этих кириллических букв кодируется двумя байтами в UTF-8, который использует исходный файл Go. Шесть букв × два байта = двенадцать. len об этом ничего не знает — он просто считает байты.

s[0] вернул 208 — это первый байт буквы «П» (0xD0). Тип результата — uint8, он же byte. Это сырой байт, не символ.

Присваивание s[0] = 'p' не скомпилируется:

cannot assign to s[0] (value of type byte)

Строка неизменяема. Индекс даёт доступ на чтение, но не на запись.

Важный нюанс: кодировка не гарантирована. Исходный файл с кириллическим литералом компилятор кодирует в UTF-8, но можно создать строку с произвольными байтами через escape-последовательности:

s2 := "\xFF\xFE\x00"  // три байта, не является корректным UTF-8
fmt.Println(len(s2))  // 3

Go примет её без ошибок. Тип string не знает ни о какой кодировке — он просто держит байты. Это принципиальное решение языка: строки пригодны для хранения любых двоичных данных, не только текста.

Тип rune, range по строке и декодирование UTF-8

Чтобы работать с текстом как с символами, нужен другой инструмент. В Go это тип rune — псевдоним int32, который представляет одну кодовую точку Unicode.

Цикл for i, r := range s делает именно то, что нужно для текста: на каждом шаге он декодирует следующую UTF-8 последовательность и возвращает байтовую позицию i и кодовую точку r типа rune.

package main

import (
    "fmt"
    "unicode/utf8"
)

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

    fmt.Println("len:", len(s)) // 12

    runeCount := 0
    for i, r := range s {
        fmt.Printf("i=%d r=%c (%d)\n", i, r, r)
        runeCount++
    }

    fmt.Println("итераций range:", runeCount)                        // 6
    fmt.Println("utf8.RuneCountInString:", utf8.RuneCountInString(s)) // 6
}

Вывод:

len: 12
i=0  r=П (1055)
i=2  r=р (1088)
i=4  r=и (1080)
i=6  r=в (1074)
i=8  r=е (1077)
i=10 r=т (1090)
итераций range: 6
utf8.RuneCountInString: 6

Обратите внимание: i прыгает на 2 каждый шаг, потому что каждая буква занимает два байта. Это не константа для всей кириллицы — просто так кодируются те конкретные буквы, что использованы здесь. Другие Unicode-символы могут занимать 1, 3 или 4 байта.

Для сравнения, побайтовый цикл:

byteCount := 0
for i := 0; i < len(s); i++ {
    fmt.Printf("s[%d] = %d\n", i, s[i])
    byteCount++
}
fmt.Println("байтовых итераций:", byteCount) // 12

Одна и та же строка, разное число итераций: 6 при range и 12 при побайтовом обходе. range декодирует, побайтовый цикл — нет.

Что происходит при некорректном байте? Если range встречает последовательность, которая не является валидным UTF-8, он подставляет utf8.RuneError (это константа '\uFFFD', значение 65533) и продвигает i ровно на один байт. Это поведение предсказуемо и задокументировано.

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

Важная оговорка: utf8.RuneCountInString считает кодовые точки, а не видимые символы. Один видимый знак может состоять из нескольких кодовых точек — например, при использовании комбинируемых знаков. В слове Привет каждая показанная буква представлена одной руной; в общем случае это не гарантируется.

Преобразование 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] — просто четвёртый байт, который в середине кириллической строки окажется продолжением многобайтовой последовательности, а не самостоятельным символом.

Задача 1

Строка объявлена как s := "Да". Чему равно len(s)?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

Что выведет следующая программа?

package main

import "fmt"

func main() {
    s := "Go!"
    for i, r := range s {
        fmt.Println(i, r)
    }
}
Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Что выведет следующая программа? Введите ровно те строки, которые появятся в stdout, каждую с новой строки.

package main

import "fmt"

func main() {
    s := "AB"
    b := []byte(s)
    b[0] = 'Z'
    fmt.Println(s)
    fmt.Println(string(b))
}
Без регистрации · результат не сохраняется

Задача 4

Лимиты никнейма

При регистрации в сервисе пользователь выбирает никнейм — строку без пробелов, которая может содержать символы из самых разных письменностей мира. Поскольку разные символы занимают разное число байт в кодировке UTF-8, одна и та же строка может укладываться в один лимит, но нарушать другой.

Сервис применяет два независимых ограничения: на число байт и на число рун (кодовых точек Unicode). Никнейм считается допустимым лишь тогда, когда он не превышает оба лимита одновременно — нарушение хотя бы одного из них ведёт к отказу.

Что нужно сделать

Допишите nicknameStats. Функция получает никнейм и лимиты по рунам и байтам. Она должна вернуть фактическое число байт, число рун и признак того, что строка укладывается сразу в оба лимита. Используйте средства подсчёта из урока. Вход содержит корректный UTF-8; руны здесь — кодовые точки Unicode.

Формат ввода

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

Формат вывода

Одна строка: bytes=B runes=R accepted=yes или bytes=B runes=R accepted=no.

Пример

Ввод:
Привет
6 12

Вывод:
bytes=12 runes=6 accepted=yes
РешениеGo

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задача 5

Перевёртыш на рунах

Кириллица устроена иначе, чем латиница: каждый её символ занимает в памяти несколько байтов, и наивная попытка пройтись по строке с конца, опираясь на позиции байтов, неизбежно разрежет букву пополам и превратит текст в мусор. Чтобы обращаться с текстом корректно, нужно мыслить не байтами, а целыми символами — теми самыми неделимыми единицами, которые на практике называют рунами.

Задача состоит в том, чтобы взять произвольную строку, введённую пользователем, и получить её зеркальное отражение: последний символ становится первым, предпоследний — вторым, и так до самого начала. При этом каждый многобайтный символ должен остаться нетронутым — буква «я» не теряет ни одного из своих байтов и не сливается с соседом.

Когда зеркальная копия построена, её естественно сравнить с оригиналом. Совпадение означает, что строка читается одинаково в обоих направлениях — это свойство, которое издавна привлекает внимание и рождает симметричные слова и фразы на самых разных языках. Сравнение проводится честно: регистр не приводится к единому виду, пробелы не выбрасываются, строки берутся такими, какими их написал пользователь.

В итоге программа выводит развёрнутую строку и сообщает, совпала ли она с исходной, — два простых факта, которые вместе дают полное представление о симметрии введённого текста.

Что нужно сделать

Реализуйте функцию RuneMirror(text string) []string. Преобразуйте строку в []rune, разверните руны, соберите строку обратно и верните срез из двух строк: развёрнутый текст и yes, если он в точности равен исходному, иначе no. Регистр, пробелы и знаки препинания не изменяются.

Ввод и вывод

Функция получает одну строку и возвращает []string{развёрнутаяСтрока, признак}. Читать stdin и печатать результат не нужно.

Пример

RuneMirror("топот") возвращает []string{"топот", "yes"}, а RuneMirror("Go🙂") — []string{"🙂oG", "no"}.

РешениеGo

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Задачи к уроку

Все задачи курса →

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

Перейти к интерактивному уроку