1. Рядок у Go: байти, UTF‑8 і len
Коли ви тільки починаєте програмувати, легко уявити, що рядок — це просто набір літер. Тоді здається, що len(s) має повертати кількість літер. Така віра тримається рівно до першої зустрічі зі словами «мир» або «世界». Go не хитрує: рядок у ньому — це послідовність байтів, тож і довжина вимірюється в байтах.
У Go значення типу string зберігає послідовність байтів. Коли ви пишете len(s), ви отримуєте кількість байтів, а не «кількість літер». Для англійського тексту в ASCII це часто збігається, бо один символ кодується одним байтом. Але щойно ви переходите до UTF‑8, один символ може займати кілька байтів.
Подивімося на дуже короткий приклад:
package main
import "fmt"
func main() {
s := "Go"
fmt.Println(len(s)) // 2
}
І це не сюрприз: дві літери — два байти.
А тепер той самий приклад, але з кирилицею:
package main
import "fmt"
func main() {
s := "мир"
fmt.Println(len(s)) // 6 (байтів), а не 3
}
UTF‑8: символи бувають «широкими»
Якщо ви чули про UTF‑8 і подумали, що це складна тема, ви не самі. Але на базовому рівні ідея дуже проста: один символ Unicode може займати від 1 до 4 байтів. Саме тому len("мир") не дорівнює 3, а len("世") на перший погляд ніби ламає звичну картину.
Важливо запам’ятати один факт: вихідні файли Go мають бути в UTF‑8 — це частина правил гри для цієї мови. Завдяки цьому код Go та інструменти не повинні підтримувати десятки кодувань. Це не робить обробку тексту магічною, але робить її значно передбачуванішою.
Можна уявляти UTF‑8 як «упаковку» тексту в байти:
flowchart LR
A["Рядок (string)"] --> B["байти UTF-8"]
B --> C["'H' = 1 байт"]
B --> D["'м' = 2 байти"]
B --> E["'世' = 3 байти"]
B --> F["емодзі часто займає 4 байти"]
Go зберігає саме байти. А коли вам потрібно проходитися по символах, у гру вступають rune і range.
rune — це кодова точка, а не «рядок довжини 1»
Слово rune звучить так, ніби зараз ми викличемо дракона, але насправді все значно простіше: rune — це ціле число, яке представляє одну Unicode-кодову точку, тобто код символу. У Go символьні літерали в одинарних лапках ('A', '世', '\n') за замовчуванням мають тип rune, а сам rune є аліасом для int32.
Найважливіше: rune — це число. Його можна вивести як число, а можна — як символ, якщо вибрати правильний формат.
package main
import "fmt"
func main() {
r := '世'
fmt.Printf("%T\n", r) // int32
fmt.Printf("%d\n", r) // 19990 (код)
fmt.Printf("%c\n", r) // 世
}
Зверніть увагу на «перемикання режимів» через формат:
- %d друкує число (код),
- %c друкує символ,
- %T друкує тип.
2. Обхід рядка: range і байтові індекси
range по рядку декодує UTF‑8
Тепер головна магія дня — цілком легальна й безпечна: коли ви пишете for ... range s для рядка s, Go читає його як UTF‑8-текст і на кожній ітерації повертає вам один символ у вигляді rune.
Синтаксис такий:
for i, r := range s {
// i — індекс у байтах
// r — rune (символ)
}
Тут важливо не потрапити в пастку: i — це байтова позиція в рядку, а не «номер символу». Це дуже корисно, але якщо переплутати, буде боляче.
Подивімося на це в дії:
package main
import "fmt"
func main() {
s := "Hi, 世界"
fmt.Println("len bytes:", len(s)) // len bytes: 10
for i, r := range s {
fmt.Printf("i=%d r=%c\n", i, r)
}
}
Типовий вивід буде приблизно таким. Точні індекси тут важливі, а порядок символів — очевидний:
- i=0 r=H
- i=1 r=i
- i=2 r=,
- i=3 r=
- i=4 r=世
- i=7 r=界
І тут видно головне: '世' починається на байті 4, а наступний символ, '界', — на байті 7. Тобто '世' займає 3 байти.
Як порахувати «довжину в символах» через range
Після попереднього розділу виникає практичне питання: «Добре, len(s) — це байти. А як дізнатися кількість символів?»
Базовий спосіб, який не потребує нових понять, — просто порахувати кількість ітерацій range. Кожна ітерація відповідає одній rune.
package main
import "fmt"
func main() {
s := "Hi, 世界"
n := 0
for range s {
n++
}
fmt.Println("runes:", n) // runes: 6
}
Чому 6? Тому що в рядку є такі символи: H, i, ,, пробіл, 世, 界.
Якщо вам хочеться оформити це як функцію, а ми вже вміємо писати функції, можна зробити так:
package main
import "fmt"
func runeCount(s string) int {
n := 0
for range s {
n++
}
return n
}
func main() {
fmt.Println(runeCount("мир")) // 3
}
Цей прийом часто використовують у прикладному коді, тому що він простий і читабельний: «рахуємо руни через range».
Чому s[0] — не «перший символ рядка»
Майже кожен новачок, та й багато досвідчених, якщо чесно, хоча б раз пише s[0] і очікує «першу літеру». У Go s[0] — це перший байт, а не перший символ. Для ASCII це збігається, для UTF‑8 — ні.
Ось демонстрація:
package main
import "fmt"
func main() {
s := "мир"
fmt.Println(s[0]) // 208 (перший байт UTF-8, не 'м')
}
Правильний «перший символ» можна дістати через range: достатньо взяти першу ітерацію й одразу вийти.
package main
import "fmt"
func firstRune(s string) (rune, bool) {
for _, r := range s {
return r, true
}
return 0, false
}
func main() {
r, ok := firstRune("мир")
fmt.Println(ok, string(r)) // true м
}
Тут ми повертаємо bool, щоб коректно обробити порожній рядок. Це трохи більш «дорослий» стиль: порожній рядок — нормальний випадок, а не привід для паніки.
3. Практика: обмеження і зрізи по рунах
Обмеження довжини тексту в «символах»
Уявімо, що ми пишемо маленький консольний список справ, а задачі поки що зберігаємо просто як рядки, без структур — до них ми доберемося пізніше. Ми хочемо встановити людське обмеження: наприклад, назва задачі має бути не довшою за 20 символів, щоб вивід не перетворювався на суцільну стрічку.
Якщо робити це через len(title), то українські чи російські назви будуть вважатися довшими, ніж англійські, бо займають більше байтів. Отже, перевіряти треба за рунами.
Зробімо функцію isTooLongTitle, яка порівнює кількість рун із лімітом:
package main
import "fmt"
func isTooLongTitle(title string, maxRunes int) bool {
n := 0
for range title {
n++
}
return n > maxRunes
}
func main() {
fmt.Println(isTooLongTitle("купити молоко", 20)) // false
fmt.Println(isTooLongTitle("прочитати дуже довгу книгу", 20)) // true
}
Зверніть увагу: ця логіка працює однаково для кирилиці, латиниці й «世界», тому що ми рахуємо символи як rune, а не байти.
Як взяти перші N рун без []rune
Іноді вам потрібно взяти «перші N символів» рядка. Найпряміший шлях — перетворити його на []rune, але це вже окрема тема й окрема ціна в пам’яті. Зараз зробимо трюк, який повністю вкладається в матеріал про range: ми знайдемо байтову позицію, де закінчується N-та руна, і візьмемо зріз рядка по байтах, але по правильній межі.
Ідея така: range дає нам i — байтовий індекс початку поточної руни. Якщо ми рахуємо руни, то, коли дійшли до N-тої, уже знаємо байтову межу.
package main
import "fmt"
func prefixRunes(s string, n int) string {
if n <= 0 {
return ""
}
count := 0
cut := len(s)
for i := range s {
if count == n {
cut = i
break
}
count++
}
return s[:cut]
}
func main() {
fmt.Println(prefixRunes("Hi, 世界!", 5)) // Hi, 世
}
Чому це працює? Тому що i, який приходить із range, завжди вказує на початок чергового UTF‑8 символу. Отже, s[:i] завжди ріже рядок по межі символів, а не посередині байтів.
Тут є тонкий момент: у for i := range s змінна i проходить байтові індекси початків рун, і перша ітерація майже завжди i=0. Тому ми акуратно рахуємо count і вибираємо cut, коли вже набрали N рун.
Таблиця для самоперевірки: байти й руни
Дуже корисно кілька разів побачити різницю на тих самих рядках. Нижче таблиця — це як рентген: видно те, що зазвичай приховано.
| Рядок | (байти) |
Кількість рун (через ) |
Коментар |
|---|---|---|---|
|
|
|
ASCII: 1 символ = 1 байт |
|
|
|
кирилиця часто займає 2 байти на літеру |
|
|
|
один символ, але 3 байти |
|
|
|
суміш ASCII та CJK |
Якщо ваша програма «ламається» на не-ASCII рядках, зазвичай причина одна з двох: або ви рахували байти як символи, або різали рядок зрізом за неправильним індексом, посередині UTF‑8 символу. Сьогодні ми закрили обидві проблеми.
4. Типові помилки під час роботи з rune і range
Помилка №1: вважати, що len(s) — це кількість символів.
Це дуже людська помилка, тому що на англійських прикладах усе виглядає ідеально. Але в Go len(s) повертає кількість байтів у UTF‑8-представленні рядка. Щойно з’являється кирилиця або «世界», довжина раптом стає більшою. Допомагає проста звичка: len для string — це байти, а range — це символи.
Помилка №2: думати, що s[i] повертає символ.
Індексація рядка в Go повертає один байт (uint8), тобто шматочок UTF‑8, але не обов’язково цілий символ. На ASCII це збігається, на UTF‑8 — зазвичай ні. Якщо вам потрібен саме символ, беріть rune через range або іншим символьним способом, а не через s[i].
Помилка №3: плутати зміст змінної i у for i, r := range s.
Змінна i тут — байтова позиція початку чергової руни, а не «номер символу». Якщо почати використовувати i як лічильник символів, майже гарантовано отримаєте кривий результат. Добра звичка — називати такі речі явно: byteIndex для i і окремий count для номера руни.
Помилка №4: різати рядок s[a:b] «по символах», підставляючи туди лічильник.
Зріз рядка ріже по байтах. Якщо ви підставите туди «номер символу», рядок може бути обрізаний посередині UTF‑8 символу, і результат стане некоректним текстом. Правильний шлях — або знаходити байтові межі через range (як у prefixRunes), або використовувати інше представлення даних, якщо задача потребує частих операцій «по символах».
Помилка №5: не обробляти порожній рядок під час спроби взяти перший символ.
Функція «дай перший символ» зобов’язана коректно працювати для "". Якщо цього не передбачити, ви або повернете сміття, або почнете писати костилі. Простий і читабельний підхід — повертати (rune, bool), де bool показує, чи знайшли символ узагалі.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ