1. Вступ
Коли людина тільки починає програмувати, операції з рядками здаються простими: «склеїв +, вивів — і готово». Але реальність швидко псує картину: зʼявляються пробіли по краях введення, кілька розділювачів поспіль, порожні елементи, а ще раптом треба зібрати великий рядок у циклі так, щоб програма не витрачала зайвий час на постійне копіювання даних. Саме тут стандартна бібліотека Go стає дуже корисною: пакети strings і bytes дають готові, читабельні рішення для типових задач.
Головна думка тут дуже проста: якщо ваші дані мають тип string, майже завжди починайте з пакета strings. Якщо ж дані подані як []byte — наприклад, ви вже працюєте на рівні байтів або збираєте байтовий буфер, — тоді майже ті самі операції є в пакеті bytes. І так, ці пакети схожі навмисно, щоб вам було простіше переходити між ними.
2. strings: TrimSpace, Split, Join
Коли ви бачите введення або дані «ззовні» — від користувача, із файла чи з мережі, — вони майже ніколи не бувають ідеальними. У них трапляються зайві пробіли, дивні переноси рядків, коми з пробілом, а інколи й просто порожній рядок. У strings є невеликий набір функцій, які допомагають привести такі дані до зручного вигляду. Сьогодні зосередимося на трьох: TrimSpace, Split і Join.
Почнімо з простого сценарію з нашого навчального застосунку. Уявімо, що ми хочемо зберігати теги задачі як рядок через кому, але люди вводять їх абияк: " go, strings, utf-8 ". Нам потрібно отримати акуратний список тегів, а потім зібрати його назад у гарний рядок.
strings.TrimSpace: чистимо краї
TrimSpace видаляє пробільні символи з країв рядка — зліва і справа. Важливо не чекати від нього магії: пробілів усередині рядка він не чіпає. Це як струсити сніг із куртки, а не прибирати всю квартиру.
package main
import (
"fmt"
"strings"
)
func main() {
raw := " go, strings, utf-8 "
clean := strings.TrimSpace(raw)
fmt.Printf("%q\n", raw) // " go, strings, utf-8 "
fmt.Printf("%q\n", clean) // "go, strings, utf-8"
}
Зверніть увагу на %q: він друкує рядок у лапках. Це корисно навіть тоді, коли рядок здається нормальним, бо насправді там можуть бути \n, \r або \t. Але тему діагностики ми розберемо окремо; сьогодні просто звикаємо до того, що %q — хороший ліхтарик.
strings.Split: розбиваємо рядок на частини
Split ділить рядок за точним розділювачем. Це важлива деталь. Якщо ви вказали розділювач ",", він шукатиме саме кому. Якщо у ваших даних ", " — тобто кома й пробіл, — це вже інший розділювач, і Split не буде нічого вгадувати.
package main
import (
"fmt"
"strings"
)
func main() {
s := "go,strings,utf-8"
parts := strings.Split(s, ",")
fmt.Println(parts) // [go strings utf-8]
}
Тут корисно запам’ятати поведінку на граничних випадках. Якщо розділювач не знайдено, Split повертає слайс з одного елемента — вихідного рядка. Якщо розділювач трапляється підряд, з’являються порожні елементи. Це не баг, а чесне відображення даних. Наприклад, рядок "a,,b" дає ["a", "", "b"].
strings.Join: склеюємо частини назад
Join виконує зворотну операцію: бере []string і з’єднує елементи, вставляючи між ними розділювач. Це зручно, коли ви вже обробили частини окремо й хочете зібрати підсумковий рядок.
package main
import (
"fmt"
"strings"
)
func main() {
parts := []string{"go", "strings", "utf-8"}
out := strings.Join(parts, ", ")
fmt.Println(out) // go, strings, utf-8
}
Є ще одна зручна деталь: зв’язка Split → обробка частин → Join зазвичай читається дуже природно, особливо якщо дати змінним чесні імена: rawTags, tagsParts, normalizedTags.
3. bytes: ті самі операції для []byte
Якщо strings — це інструменти для string, то bytes — інструменти для []byte. Переходити на []byte варто не тому, що це «крутіше», а тоді, коли вам справді потрібно працювати на рівні байтів або коли дані вже подано саме так. Наприклад, ви читаєте щось як байти, копіюєте їх, буферизуєте або накопичуєте. Важливо, що багато операцій тут дзеркально повторюють strings, а це економить нерви.
Нижче — маленька таблиця-шпаргалка. Вона не претендує на повний довідник, але для сьогоднішньої теми цього цілком досить.
| Завдання | Для string | Для []byte |
|---|---|---|
| Прибрати пробільні символи по краях | |
|
| Розбити за розділювачем | |
|
| Склеїти частини з розділювачем | |
|
Подивімося на байтову версію розбиття та склеювання. Зверніть увагу: у bytes.Split розділювач теж має тип []byte, а результат — [][]byte, тобто слайс із слайсів.
package main
import (
"bytes"
"fmt"
)
func main() {
b := []byte("go,strings,utf-8")
parts := bytes.Split(b, []byte(","))
fmt.Println(parts) // [[103 111] [115 116 114 105 110 103 115] [117 116 102 45 56]]
}
Це виглядає страшніше, бо fmt.Println друкує байти як числа. Але логіка та сама: отримали частини, далі можете їх обробляти, а потім склеїти назад:
package main
import (
"bytes"
"fmt"
)
func main() {
parts := [][]byte{[]byte("go"), []byte("bytes")}
out := bytes.Join(parts, []byte(" + "))
fmt.Println(string(out)) // go + bytes
}
Зверніть увагу: наприкінці ми виконуємо string(out), бо хочемо красиво вивести текст. Це нормальне й звичне перетворення представлення даних.
4. Збирання результату: strings.Builder і bytes.Buffer
До цього місця ми виконували операції одним кроком: розрізали й склеювали. Але часто задача інша — ми йдемо циклом і поступово нарощуємо результат. Наприклад, будуємо зрозумілий рядок звіту, форматуємо список тегів або збираємо повідомлення з кількох рядків. І от тут наївний код починає виглядати так: result = result + piece. Він працює, але може бути дуже ненажерливим до пам’яті, бо рядок є незмінним, і кожне + створює новий рядок.
Щоб не влаштовувати комп’ютеру нескінченне переклеювання шпалер, Go пропонує накопичувачі. Для рядків це strings.Builder, для байтів — bytes.Buffer. Їх можна уявляти як коробку, куди ви складаєте шматочки, а наприкінці отримуєте готовий результат.
strings.Builder: накопичуємо рядок
strings.Builder зручний тоді, коли підсумок вам потрібен саме як string. Його нульове значення вже готове до роботи: можна оголосити var b strings.Builder і відразу писати в нього.
package main
import (
"fmt"
"strings"
)
func main() {
var b strings.Builder
b.WriteString("Теги: ")
b.WriteString("go")
b.WriteString(", ")
b.WriteString("strings")
fmt.Println(b.String()) // Теги: go, strings
}
Тут важливо, що ми не робимо result += .... Натомість ми додаємо шматки в Builder. Наприкінці викликаємо b.String() і отримуємо підсумковий рядок.
bytes.Buffer: накопичуємо байти
bytes.Buffer схожий за ідеєю, але орієнтований на []byte. Це зручно, якщо ви збираєте байтові дані або хочете пізніше отримати байти, наприклад щоб передати їх далі як []byte. Поки що ми не занурюємося в I/O та потоки, тому розглядаємо Buffer просто як байтовий конструктор результату.
package main
import (
"bytes"
"fmt"
)
func main() {
var buf bytes.Buffer
buf.WriteString("ID=")
buf.WriteString("42")
fmt.Println(buf.String()) // ID=42
}
Так, bytes.Buffer теж уміє String(). Це зручно: зібрали байти, а вивести можна як рядок.
Важливий момент: що означає x.F(...)
Досі ви часто бачили виклики на кшталт fmt.Println(...). Там після fmt стоїть крапка, але це не об’єктний стиль у класичному ООП-розумінні, а звернення до функції всередині пакета: fmt — назва пакета, Println — назва функції.
З Builder і Buffer ми бачимо схожий запис: b.WriteString("..."). І ось тут крапка означає інше: b — це значення (змінна), а WriteString — операція, «прикріплена» до типу цього значення. Така операція називається методом. Тобто запис x.F(...) читається як «викликати метод F на значенні x».
Для старту достатньо запам’ятати дві відмінності — і код читатиметься значно легше. У fmt.Println(...) ми викликаємо функцію з пакета. У b.WriteString(...) ми викликаємо метод у значення b.
Перевірімо це на маленькому прикладі, щоб мозок остаточно погодився:
package main
import (
"fmt"
"strings"
)
func main() {
var b strings.Builder
fmt.Printf("%T\n", b) // strings.Builder
b.WriteString("Go")
fmt.Println(b.String()) // Go
}
Тут fmt.Printf — це функція пакета fmt, а WriteString і String — методи Builder. Жодної магії — просто дві різні ситуації з однаковою крапкою.
5. Приклад: нормалізуємо теги
Щоб усе це не залишилося набором окремих трюків, зберімо їх в один невеликий сценарій нашого навчального застосунку. Нехай у нас є сире введення тегів — поки що просто рядок у коді — і ми хочемо зробити дві речі: отримати акуратний []string тегів і зібрати гарний рядок для друку.
Схематично це виглядає так:
flowchart TD
A["сирий рядок ' go, strings, utf-8 '"] --> B["TrimSpace"]
B --> C["Split за ','"]
C --> D["TrimSpace для кожної частини"]
D --> E["Join with ', '"]
E --> F["Builder: 'Теги: ' + joined"]
Нормалізація: TrimSpace + Split + цикл + Join
Ми свідомо будуємо невеликий ланцюжок обробки: чистимо краї всього рядка, ріжемо його за комами, очищуємо краї в кожного шматочка, а потім склеюємо все назад уже акуратно.
package main
import (
"fmt"
"strings"
)
func main() {
raw := " go, strings, utf-8 "
raw = strings.TrimSpace(raw)
parts := strings.Split(raw, ",")
for i := 0; i < len(parts); i++ {
parts[i] = strings.TrimSpace(parts[i])
}
fmt.Println(strings.Join(parts, ", ")) // go, strings, utf-8
}
Тут важливо, що ми використовуємо індексний for, бо хочемо змінити елементи parts[i]. Це звична практика зі слайсами: змінювати елементи простіше й зрозуміліше саме через індекси.
Збирання фінального рядка через strings.Builder
Тепер нехай ми хочемо отримати рядок вигляду: Теги: go, strings, utf-8. Так, можна написати fmt.Println("Теги:", strings.Join(...)), і для такого випадку це нормально. Але Builder особливо корисний тоді, коли формат складніший або ви будуєте текст у кілька кроків. Покажімо це окремим фрагментом того самого сценарію.
package main
import (
"fmt"
"strings"
)
func main() {
tags := []string{"go", "strings", "utf-8"}
var b strings.Builder
b.WriteString("Теги: ")
b.WriteString(strings.Join(tags, ", "))
fmt.Println(b.String()) // Теги: go, strings, utf-8
}
Builder тут хороший тим, що код читається як складання фрази: спочатку пишемо префікс, потім дописуємо список.
Байтовий варіант: bytes.Split/Join + bytes.Buffer
Іноді зручно тримати все як байти. Наприклад, ви отримали дані як []byte і не хочете ганяти туди-сюди конвертації без потреби. Покажімо дзеркальний варіант. Він виглядає трохи більш низькорівневим, але логіка та сама.
package main
import (
"bytes"
"fmt"
)
func main() {
raw := []byte(" go, bytes, utf-8 ")
raw = bytes.TrimSpace(raw)
parts := bytes.Split(raw, []byte(","))
for i := 0; i < len(parts); i++ {
parts[i] = bytes.TrimSpace(parts[i])
}
var buf bytes.Buffer
buf.WriteString("Теги: ")
buf.Write(bytes.Join(parts, []byte(", ")))
fmt.Println(buf.String()) // Теги: go, bytes, utf-8
}
Зверніть увагу на рядок buf.Write(...): bytes.Join повертає []byte, і ми записуємо їх у буфер. Наприкінці друкуємо buf.String().
6. Типові помилки
Помилка № 1: очікувати, що TrimSpace чистить пробіли всюди.
Часто новачки застосовують TrimSpace, бачать, що краї стали гарними, і підсвідомо думають: «ну тепер рядок чистий». А потім дивуються, що "a b" так і лишається "a b". Це нормальна поведінка: TrimSpace працює з краями, а не з внутрішньою частиною рядка. Якщо вам потрібно змінювати середину, це вже інша задача й інші інструменти.
Помилка № 2: плутати «розділювач як ідею» і «розділювач як точний підрядок».
strings.Split(s, ",") ріже лише за комою. Якщо в даних є кома й пробіл, а ви ріжете тільки за комою, пробіл залишиться на початку наступного шматочка. Це не помилка Split, а просто особливість вхідних даних. Практичний спосіб працювати спокійно — після Split майже завжди робити TrimSpace для кожної частини, якщо формат допускає зайві пробіли.
Помилка № 3: дивуватися порожнім елементам після Split.
Рядок на кшталт "a,,b" породжує порожній елемент між двома комами. Люди інколи думають, що «порожнє» треба автоматично викидати. Але Split — не ворожка і не редактор: він чесно показує структуру даних. Якщо порожні елементи вам не потрібні, це вже окреме рішення — фільтрація слайса. Ми вміємо робити це циклом, не вдаючись до зайвої магії.
Помилка № 4: збирати рядок у циклі через result += piece і не розуміти, чому все раптом стало повільним.
На коротких рядках різниці майже не видно, тому може скластися враження, що Builder — «для зануд». Але зі зростанням даних постійне створення нових рядків починає коштувати дорого. Якщо ви будуєте результат шматками в циклі, Builder і Buffer — це не мікрооптимізація, а просто правильніший інструмент.
Помилка № 5: не розуміти різницю між fmt.Println і b.WriteString.
Обидва записи використовують крапку, і це збиває з пантелику. У fmt.Println крапка означає звернення до функції в пакеті. У b.WriteString крапка означає виклик методу на значенні. Якщо в голові розвести ці дві ситуації, код стандартної бібліотеки починає читатися набагато легше, і ви перестаєте сприймати методи як щось складне чи «з майбутнього».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ