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.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ