JavaRush /Курси /Go SELF /Конвертації []byte ...

Конвертації []byte і []rune та коли вони доречні

Go SELF
Рівень 14 , Лекція 2
Відкрита

1. Навіщо потрібні []byte і []rune

Коли ви вперше дізнаєтеся, що рядок можна перетворити на слайс, виникає бажання: «О, тепер я нарешті зможу змінювати рядок за індексом!». Go на це спокійно відповідає: «Можете, але не напряму». Рядки в Go незмінні: це корисно і для продуктивності, і для безпеки, і для вашого майбутнього спокою.

Тому будь-які зміни рядка виконують через створення нового рядка. А щоб зручно змінювати вміст, ми зазвичай на певний час переводимо рядок у більш придатну форму:

  • []byte — коли ми хочемо працювати з байтами (часто це про ASCII-символи, табуляції, переведення рядка, протоколи, «сирий» ввід і вивід, бінарні дані).
  • []rune — коли ми хочемо працювати із символами Unicode (тобто «по-людськи»: літери, зокрема кирилиця, китайські ієрогліфи тощо).

Ключова річ тут у тому, що індексування рядка s[i] працює з байтами й повертає byte (по суті uint8), а не «символ».

2. Модель даних і копіювання під час конвертації

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

Схема перетворень

Ось схема у форматі Mermaid:

flowchart LR
    S["string (байти UTF-8)"]

    B["[]byte (байти)"]
    R["[]rune (символи / кодові точки)"]

    S -->|"[]byte(s)"| B
    S -->|"[]rune(s)"| R

    B -->|"string(b)"| S2["string (інтерпретація байтів як UTF-8)"]
    R -->|"string(r)"| S3["string (кодування рун у UTF-8)"]

    note1["Кожне перетворення створює нове значення\n(копіювання даних)"]

    S -.-> note1
    B -.-> note1
    R -.-> note1

Важливо: перетворення створюють нові значення. Це не «вид» на ті самі дані, а саме новий слайс або новий рядок.

Чому зміни []byte(s) не змінюють вихідний рядок

Новачкам часто хочеться думати так: «якщо я зробив b := []byte(s), то b ніби вказує на рядок, і якщо я зміню b, зміниться s». Це не так.

Правильна ментальна модель така: перетворення створює нове значення, а рядок залишається незмінним.

package main

import "fmt"

func main() {
	s := "go"
	b := []byte(s)
	b[0] = 'G'

	fmt.Println(s)         // go
	fmt.Println(string(b)) // Go
}

Це, до речі, одна з причин, чому рядки в Go можна спокійно передавати куди завгодно: вони незмінні, тож ніхто не зіпсує їх «за вашою спиною».

3. Робота з байтами: []byte(s)

Якщо ви працюєте з рядком як із набором байтів (наприклад, хочете замінити "\t" на пробіл або прибрати "\r"), то []byte — ваш друг. Байти зручно змінювати на місці: b[i] = ..., можна використовувати append, можна робити зрізи.

Почнімо з короткої перевірки типів і значень. Зверніть увагу: byte — це діапазон 0..255, тобто по суті uint8.

package main

import "fmt"

func main() {
	s := "Go"
	b := []byte(s)

	fmt.Printf("%T %v\n", b[0], b[0]) // uint8 71
	fmt.Printf("%T %v\n", b[1], b[1]) // uint8 111
}

Тут 71 і 111 — це коди байтів ('G' і 'o' в ASCII). Поки рядок складається з ASCII-символів, усе здається дуже інтуїтивним, але це пастка: у UTF-8 далеко не всі символи вміщуються в один байт.

Патерн «змінити рядок» через []byte

Найчастіший практичний сценарій — коли рядок надійшов із вводу, у ньому є технічні символи, і ви хочете його нормалізувати. Зробімо невеликий крок убік до нашого навчального міні-застосунку (назвемо його умовно textlab): він прийматиме рядок і приводитиме його до більш охайного вигляду.

Наприклад, замінимо всі таби "\t" на один пробіл. Це байтова операція: табуляція — один байт, пробіл — теж один байт.

package main

import "fmt"

func replaceTabsASCII(s string) string {
	b := []byte(s)

	for i := 0; i < len(b); i++ {
		if b[i] == '\t' {
			b[i] = ' '
		}
	}

	return string(b)
}

func main() {
	fmt.Println(replaceTabsASCII("A\tB\tC")) // A B C
}

Що тут важливо помітити.

По-перше, ми не намагалися зробити s[i] = ... — так не можна, рядок незмінний.

По-друге, string(b) створює новий рядок на основі байтового слайса.

По-третє, цей підхід коректний, поки ви змінюєте саме технічні ASCII-байти ("\t", "\n", "\r", пробіл). Для літер кирилиці так робити небезпечно: символ може бути багатобайтним, і «влучити» в середину символу дуже легко.

«Збираємо байти», а потім робимо string(b)

Іноді вхідний рядок великий, а ви хочете зібрати новий результат із фрагментів. Можна робити це через append до []byte. Це поки що ручний стиль, без спеціальних накопичувачів — до них ми дійдемо пізніше, а зараз корисно відчути механіку.

Нехай textlab додає обрамлення до рядка:

package main

import "fmt"

func wrapWithBrackets(s string) string {
	var b []byte
	b = append(b, '[')
	b = append(b, s...) // рядок розкладається на байти
	b = append(b, ']')
	return string(b)
}

func main() {
	fmt.Println(wrapWithBrackets("go"))   // [go]
	fmt.Println(wrapWithBrackets("світ")) // [світ]
}

Тут важливий момент: append(b, s...) додає байти UTF-8. Це нормально: ми не ламаємо UTF-8, бо додаємо рядок цілком, байт за байтом. Ми нічого не намагаємося замінити всередині символів.

4. Робота з рунами: []rune(s)

Чому []byte небезпечний для «літер»

Дуже типова ситуація: ви хочете зробити рядок «гарним» — наприклад, першу літеру великою. В англійському тексті інколи спрацьовує байтовий трюк, але на реальному тексті (кирилиця, японська, emoji) він швидко ламається.

Щоб відчути проблему, важливо пам’ятати: символьні літерали в одинарних лапках за замовчуванням мають тип rune (це аліас для int32). А byte — це uint8, і туди не вміщуються великі значення.

Ось приклад, який показує, чому «покласти кирилицю в byte» — погана ідея (цей код не компілюється, і це нормально):

package main

func main() {
	// var x byte = 'М' // не можна: 'М' не вміщується в byte (0..255)
}

Тож якщо вам потрібно працювати з «літерами», вам потрібен рівень rune.

[]rune(s) і безпечна робота «по символах»

[]rune(s) робить важливу річ: бере рядок UTF-8 і декодує його в послідовність символів, точніше, Unicode-кодових точок. Це сильно спрощує життя, коли вам потрібно взяти «перший символ», «останній символ», «третій символ» або замінити один символ на інший, не боячись порвати UTF-8.

Мініприклад: замінимо перший символ на інший — саме як символ, а не як байт.

package main

import "fmt"

func main() {
	s := "світ"
	r := []rune(s)
	r[0] = 'С'
	fmt.Println(string(r)) // Світ
}

Тут r[0] — це перша руна, тобто перша літера, навіть якщо в UTF-8 вона займає 2 байти.

Патерн для textlab: замінити перший символ

Зробімо функцію для нашого міні-застосунку: якщо рядок непорожній, замінимо перший символ на заданий. Це демонстраційна функція, але вона добре закріплює ідею: з текстом працюємо через руни.

package main

import "fmt"

func replaceFirstRune(s string, newFirst rune) string {
	r := []rune(s)
	if len(r) == 0 {
		return s
	}
	r[0] = newFirst
	return string(r)
}

func main() {
	fmt.Println(replaceFirstRune("hello", 'H')) // Hello
	fmt.Println(replaceFirstRune("світ", 'С'))  // Світ
}

Зверніть увагу на перевірку len(r) == 0. Це не пересторога без причини, а звичайна акуратність: порожній рядок — цілком нормальний випадок у реальній програмі.

5. Комбінуємо підходи в textlab

Іноді студенти намагаються запам’ятати, у яких задачах потрібні []byte, а в яких []rune, як закляття. Набагато корисніше розуміти сенс: ви обираєте представлення даних під конкретну операцію.

Як обрати представлення

Що ви робите Що зручніше Чому
Прибираєте або замінюєте "\r", "\n", "\t", пробіли на початку, у кінці або всередині (як байти) []byte Це керувальні ASCII-байти, тож на байтовому рівні їх легко й безпечно змінювати
Додаєте до рядка фрагменти, збираєте результат із частин []byte (або пізніше спеціальні накопичувачі) Зручно накопичувати байти й один раз перетворити їх на рядок
Хочете «перший/останній символ», заміну літер, роботу з кирилицею/Unicode []rune Вам потрібні саме символи, а не байти UTF-8
Хочете дізнатися довжину в символах (спрощено) []rune або range len(s) дає кількість байтів, а не символів

Тут немає магії: якщо операція «символьна» — беріть руни; якщо «технічно-байтова» — беріть байти.

Мініверсія textlab

Тепер зберімо все в невеликий, але цілісний приклад: програма читає рядок (поки що просто задає його в коді), замінює таби на пробіли (байтова нормалізація), а потім замінює перший символ на заданий (символьна операція). Це хороший приклад того, що інколи в одному ланцюжку доречні обидва представлення.

package main

import "fmt"

func replaceTabsASCII(s string) string {
	b := []byte(s)
	for i := 0; i < len(b); i++ {
		if b[i] == '\t' {
			b[i] = ' '
		}
	}
	return string(b)
}

func replaceFirstRune(s string, newFirst rune) string {
	r := []rune(s)
	if len(r) == 0 {
		return s
	}
	r[0] = newFirst
	return string(r)
}

func main() {
	s := "\tсвіт\tgo"
	s = replaceTabsASCII(s)
	s = replaceFirstRune(s, '>')
	fmt.Println(s) // >світ go
}

Тут ви бачите ознаку зрілого коду: ми не обираємо один правильний тип назавжди. Ми вибираємо тип на кожному кроці так, щоб конкретна операція була простою й безпечною.

6. Типові помилки

Помилка №1: намагатися змінювати рядок за індексом (s[i] = ...).
Рядки в Go незмінні, і компілятор не дасть вам цього зробити. Правильний шлях — створити нове значення: або через конкатенацію підрядків, або через []byte/[]rune і зворотне складання в string.

Помилка №2: використовувати []byte для «символьних» операцій на Unicode-тексті.
На ASCII це інколи випадково працює, і саме тому помилка стає підступнішою: здається, ніби все нормально. Але щойно з’являється кирилиця або будь-який багатобайтний символ, байтові індекси починають різати рядок не по символах, і ви можете зіпсувати UTF-8. Якщо ви мислите літерами, використовуйте []rune.

Помилка №3: очікувати, що зміна b := []byte(s) змінить вихідний рядок s.
Перетворення створює нове значення: рядок залишається колишнім. Тому після зміни байтів або рун потрібно явно зібрати новий рядок через string(b) або string(r). Якщо в коді забути цей крок, ви будете змінювати не те й дивуватися, чому результат не змінюється.

Помилка №4: намагатися покласти не-ASCII символ у byte.
byte — це uint8 (0..255). Багато символів Unicode більші за 255, тому вирази на кшталт var x byte = 'М' не компілюються. Це не прискіпливість Go, а захист від тихих помилок. Для символів використовуйте rune, який за замовчуванням має тип int32.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ