Розбираємося в Go: пакети bytes і strings

Переклад однієї з статей Бена Джонсона з серії "Go Walkthrough" за більш поглибленого вивчення стандартної бібліотеки Go в контексті реальних завдань.
попередньому пості ми розібралися, як працювати з потоками байт, але іноді нам потрібно працювати з конкретним набором байт у пам'яті. Хоча слайсы байт цілком підходять для багатьох завдань, є чимало випадків, коли краще використовувати пакет bytes. Також ми розглянемо сьогодні і пакет strings, так як його API практично ідентичний bytes, тільки він працює з рядками.
Цей пост є одним із серії статей з більш поглибленого розбору стандартної бібліотеки. Незважаючи на те, що стандартна документація надає масу корисної інформації, в контексті реальних завдань може бути непросто розібратися, що і коли використовувати. Ця серія статей спрямована на те, щоб показати використання пакетів стандартної бібліотеки в контексті реальних додатків. Якщо у вас є питання або коментарі, ви завжди можете написати мені в Твіттер — @benbjohnson.
Короткий відступ про рядках і байтах
Роб Пайк написав чудовий і глибокий пост про рядках, байтах, рунах і символах, але для цього поста я б хотів дати більш просте визначення з точки зору розробника.
Слайс байт являє собою змінний послідовний набір байт. Злегка багатослівно, тому давайте спробуємо зрозуміти, що це означає.
У нас є слайс байт:
buf := []byte{1,2,3,4}

Він змінний, тому ви можете змінювати у ньому елементи:
buf[3] = 5 // []byte{1,2,3,5}

Ви також можете змінювати його розмір:
buf = buf[:2] // []byte{1,2}
buf = append(buf, 100) // []byte{1,2,100}

І він послідовний, так як байти в пам'яті йдуть один за іншим:
1/2/3/4

Рядка ж являють собою незмінний послідовний набір байт фіксованого розміру. Це означає, що ви не можете редагувати рядки — тільки створювати нові. Це важливо розуміти в контексті продуктивності програми. У програмах, де потрібна дуже висока продуктивність, постійне створення великої кількості рядків створить відчутну навантаження на збирач сміття.
З точки зору розробника, рядки краще використовувати, коли ви працюєте з даними в UTF-8 — вони можуть бути використані як ключі до map, на відміну від слайсів байт, наприклад, і більшість API використовують рядки для подання строкових даних. З іншого боку, слайсы байт набагато краще підходять, коли вам потрібно працювати з сирими байтами, при обробці потоків даних, наприклад. Вони також зручніше, якщо ви хочете уникнути нових виділень пам'яті і хочете переиспользовать пам'ять.
Адаптуючи рядки і слайсы для потоків
Одна з найважливіших особливостей пакетів bytes і strings полягає в тому, що в них реалізовані інтерфейси io.Reader і io.Writer для роботи з байтами та рядками в пам'яті.
In-memory рідери
Два недоиспользуемых функції в стандартній бібліотеці Go це bytes.NewReader і strings.NewReader:
func NewReader(b []byte) *Reader
func NewReader(string s) *Reader

Ці функції повертають реалізації io.Reader інтерфейсу, який служить обгорткою навколо слайсу байтів або рядка в пам'яті. Але це не тільки рідери — вони так само реалізують інші суміжні інтерфейси, такі як io.ReaderAt, io.WriterTo, io.ByteReader, io.ByteScanner, io.RuneReader, io.RuneScanner і io.Seeker.
Я регулярно бачу код, де слайсы байт і рядка спочатку пишуться bytes.Buffer, а потім буфер використовується як рідер:
var buf bytes.Buffer
buf.WriteString("foo")
http.Post("http://example.com/", "text/plain", &buf)

Такий підхід вимагає зайвих аллокаций пам'яті і може бути повільним. Набагато ефективніше буде використовувати strings.Reader:
r := strings.NewReader("foobar")
http.Post("http://example.com", "text/plain", r)

Цей спосіб працює також коли у вас є багато рядків або слайсів байт, які можна об'єднати за допомогою [io.MultiReader]():
r := io.MultiReader(
strings.NewReader("HEADER"),
bytes.NewReader([]byte{0,1,2,3,4}),
myFile,
strings.NewReader("FOOTER"),
)

In-memory writer
Пакет bytes також реалізує інтерфейс io.Writer для слайсів байт в пам'яті типу Buffer. Він реалізує майже всі інтерфейси пакету io, крім io.Closer і io.Seeker. Також в ньому є допоміжний метод WriteString() для запису рядка в кінець буфера.
Я активно використовую Buffer в unit-тестах для захоплення виведення логів сервісів. Ви можете передати буфер як аргумент log.New() і перевірити висновок пізніше:
var buf bytes.Buffer
myService.Logger = log.New(&buf, "", log.LstdFlags)
myService.Run()
if !strings.Contains(buf.String(), "service failed") {
t.Fatal("expected log message")
}

Але в продакшн коді, я рідко використовую Buffer. Незважаючи на ім'я, я не використовую його для буферизированного читання і запису, так як спеціально для цього в стандартній бібліотеці є пакет bufio.
Організація пакета
На перший погляд, пакети bytes і strings здаються дуже великими, але насправді вони представляють собою просто набір простих допоміжних функцій. Ми можемо згрупувати їх за кількома категоріями:
  • Функції порівняння
  • Функції перевірки
  • Функції префіксів/суфіксів
  • Функції заміни
  • Функції об'єднання і розділення
Коли ми зрозуміємо, як ці функції згруповані, здавався великим API буде виглядати набагато більш комфортним.
Функції порівняння
Коли у вас є два слайсу байт або два рядки, вам може знадобиться отримати відповідь на два питання. Перший — чи рівні ці два об'єкти? І другий — який з об'єктів йде раніше при сортуванні?
Рівність
Функція Equal() відповідає на перше питання:
func Equal(a, b []byte) bool

Ця функція є тільки в пакеті bytes, так як рядки можна порівнювати з допомогою оператора ==.
Хоча перевірка на рівність може здатися простим завданням, є популярна помилка у використанні strings.ToUpper() для перевірки на рівність без обліку регістра:
if strings.ToUpper(a) == strings.ToUpper(b) {
return true
}

Цей підхід неправильний, він використовує 2 алокації для нових рядків. Набагато більш правильний підхід-це використання EqualFold():
func EqualFold(s, t []byte) bool
func EqualFold(s, t string) bool

Слово Fold тут означає Unicode case-folding. Воно охоплює правила для верхнього і нижнього регістра не тільки для A-Z, але і для інших мов, і вміє конвертувати φ у ϕ.
Порівняння
Щоб дізнатися порядок для сортування двох слайсів байт або рядків, у нас є функція Compare():
func Compare(a, b []byte) int
func Compare(a, b string) int

Ця функція повертає -1, якщо a менше b, 1, якщо a більше b і 0, якщо a і b рівні. Ця функція присутня в пакеті strings виключно для симетрії з bytes. Russ Cox навіть закликає до того, що "ніхто не повинен використовувати strings.Compare". Простіше використовувати вбудовані оператори < >.
"ніхто не повинен використовувати strings.Compare", Russ Cox
Зазвичай вам потрібно порівнювати слайсы байт або рядків сортування даних. Інтерфейс sort.Interface потребує функції порівняння методу Less(). Щоб перевести тернарную форму повертається Compare() логічне значення для Less(), досить просто перевірити на рівність з -1:
type ByteSlices [][]byte
func (p ByteSlices) Less(i, int j) bool {
return bytes.Compare(p[i], p[j]) == -1
}

Функції перевірки
Пакети bytes і strings надають кілька способів перевірити чи знайти значення в рядку або в слайсе байт.
Підрахунок
Якщо ви валидируете вхідні дані, може бути необхідно перевірити наявність (або відсутність) певних байт в них. Для цього можна використовувати функцію Contains():
func Contains(b, subslice []byte) bool
func Contains(s, substr string) bool

Наприклад, ви можете перевірити наявність певних нехороших слів:
if strings.Contains(input, "darn") {
return errors.New("inappropriate input")
}

Якщо ж вам потрібно знайти точну кількість входжень шуканої підрядка, ви можете використовувати Count():
func Count(s, sep []byte) int
func Count(s, sep string) int

Ще одне застосування Count() — підрахунок кількості рун в рядку. Якщо передати порожній слайс або порожній рядок у якості sep аргументу, Count() поверне кількість рун + 1. Це відрізняється від висновку len(), яка повертає кількість байт. Це різниця важлива, якщо ви працюєте з мультибайтовыми символами Unicode:
strings.Count("І ", "") // 6
len("І ") // 9

Перший рядок може здатися дивною, тому що за фактом там 5 рун, але не забувайте, що Count() повертає кількість рун плюс одиницю.
Індексування
Перевірка на входження це важливе завдання, але іноді вам потрібно знайти точну позицію підрядка або шуканого слайсу. Ви можете це зробити за допомогою функцій індексації:
Index(s, sep []byte) int
IndexAny(s []byte, chars string) int
IndexByte(s []byte, c byte) int
IndexFunc(s []byte, f func(r rune) bool) int
IndexRune(s []byte, r rune) int

Тут є кілька функцій для різних випадків. Index() шукає мультибайтові слайсы. IndexByte() знаходить одиничний байт в слайсе. IndexRune() шукає Unicode code-point в UTF-8 рядку. IndexAny() працює аналогічно IndexRune(), але шукає відразу кілька code-point-ів одночасно. На закінчення, IndexRune() дозволяє використовувати свою власну функцію для пошуку індексу.
Також є аналогічний набір функцій для пошуку першій позиції з кінця:
LastIndex(s, sep []byte) int
LastIndexAny(s []byte, chars string) int
LastIndexByte(s []byte, c byte) int
LastIndexFunc(s []byte, f func(r rune) bool) int

Я зазвичай мало використовую функції індексування, тому що частіше мені доводиться писати щось більш складне, начебто парсерів.
Префікси, суфікси і видалення
Префікси в програмуванні вам зустрінуться досить часто. Наприклад, шляхи в HTTP адреси часто згруповані по функціоналу за допомогою префіксів. Або, інший приклад — спеціальний символ на початку рядка, на кшталт "@", використовується для згадки користувача.
HasPrefix() і HasSuffix() дозволяють вам перевірити такі випадки:
func HasPrefix(s, prefix []byte) bool
func HasPrefix(s, string prefix) bool

func HasSuffix(s, suffix []byte) bool
func HasSuffix(s, suffix string) bool

Ці функції можуть здатися дуже простими, щоб з ними морочитися, але я регулярно бачу таку помилку, коли розробники забувають на перевірку нульового розміру рядки:
if str[0] == '@' {
return true
}

Цей код виглядає просто, але якщо str виявиться порожнім рядком, ви отримаєте паніку. Функція HasPrefix() містить цю перевірку:
if strings.HasPrefix(str, "@") {
return true
}

Видалити
Термін "видалення"(trimming) у пакетах bytes і strings означає видалення байт або рун на початку та/або наприкінці слайсу або рядка. Сама узагальнена для цього функція — Trim():
func Trim(s []byte, cutset string) []byte
func Trim(string s, cutset string) string

Вона видаляє всі руни з набору cutset з обох сторін — з початку і кінця рядка. Також можна видаляти тільки з початку, або тільки з кінця, використовуючи TrimLeft() і TrimRight() відповідно.
Але найчастіше використовуються більш конкретні варіанти видалення — видалення пробілів, для цього є функція TrimSpace():
func TrimSpace(s []byte) []byte
func TrimSpace(string s) string

Ви можете подумати, що видалення з cutset-му рівним "\n\r" може бути достатньо, але TrimSpace() вміє видаляти символи пробілів, визначені в Unicode. Сюди входять не тільки прогалини, переклад рядка або символ табуляції, але і такі нестандартні символи як "thin space" або "hair space".
TrimSpace(), насправді, всього лише обгортка на TrimFunc(), яка визначає символи, які будуть використовуватися для видалення:
func TrimSpace(string s) string {
return TrimFunc(s, unicode.IsSpace)
}

Таким чином можна дуже просто створити свою функцію, яка буде видаляти, скажімо, тільки прогалини в кінці рядка:
TrimRightFunc(s, unicode.IsSpace)

На закінчення, якщо ви хочете видалити не символи, а конкретну підрядок ліворуч або праворуч, то для цього є функції TrimPrefix() і TrimSuffix():
func TrimPrefix(s, prefix []byte) []byte
func TrimPrefix(s, string prefix) string

func TrimSuffix(s, suffix []byte) []byte
func TrimSuffix(s, suffix string) string

Вони йдуть рука об руку з функціями HasPrefix() і HasSuffix() для перевірки на наявність префікса або суфікса відповідно. Наприклад, я використовую їх для bash-подібного доповнення шляхів конфігураційних файлів в домашній директорії:
// Look up user's home directory.
u, err := user.Current()
if err != nil {
return err
} else if u.HomeDir == "" {
return errors.New("home directory does not exist")
}

// Replace tilde prefix with home directory.
if strings.HasPrefix(path, "~/") {
path = filepath.Join(u.HomeDir, strings.TrimPrefix(path))
}

Функції заміни
Проста заміна
Іноді необхідно замінити підрядок або частину слайсу. Для більшості простих випадків все що вам потрібно, це функція Replace():
func Replace(s, old, new []byte, int n) []byte
func Replace(s, old, new string, int n) string

Вона замінює будь входження old у вашій рядку на new. Якщо значення n дорівнює -1, то будуть замінені всі входження. Ця функція дуже добре підходить, якщо потрібно замінити просте слово за шаблоном. Наприклад, ви можете дозволити користувачеві використовувати шаблон "$NOW" і замінити його на поточний час:
now := time.Now().Format(time.Kitchen)
println(strings.Replace(data, "$NOW", now, -1)

Якщо вам необхідно замінювати відразу кілька різних входжень, використовуйте strings.Replacer. Він приймає на вхід пари старе/нове значення:
r := strings.NewReplacer("$NOW", now, "$USER", "mary")
println(r.Replace("Hello $USER, it is $NOW"))

// Output: Hello mary, it is 3:04PM

Заміна регістру
Ви можете вважати, що робота з регістрами це просто — нижній і верхній, всього-то делов — але Go працює з Unicode, а Unicode ніколи не буває простим. Є три типи регістрів: верхній, нижній і заголовний регістри.
Верхній і нижній досить прості для більшості мов, і досить використовувати функції ToUpper() і ToLower():
func ToUpper(s []byte) []byte
func ToUpper(string s) string

func ToLower(s []byte) []byte
func ToLower(string s) string

Але, в деяких мовах правила регістрів відрізняються від загальноприйнятих. Приміром, у турецькій мові, i у верхньому регістрі виглядає як I. Для таких спеціальних випадків, є спеціальні версії цих функцій:
strings.ToUpperSpecial(unicode.TurkishCase, "і")

Далі, у нас є ще заголовний регістр і функція ToTitle():
func ToTitle(s []byte) []byte
func ToTitle(string s) string

Напевно ви дуже здивуєтесь, коли побачите що ToTitle() переведе всі ваші символи у верхній регістр:
println(strings.ToTitle("the count of monte cristo"))

// Output: THE COUNT OF MONTE CRISTO

Це тому, що в Unicode заголовний регістр є спеціальним видом регістра, а не написанням першої букви у слові у верхньому регістрі. У більшості випадків, заголовний і верхній регістр це одне і теж, але є кілька code point-ів, в яких це не так. Наприклад, code point lj (так, це один code point) у верхньому регістрі виглядає як LJ, а в заголовному — lj.
Функція, яка вам потрібна в цьому випадку, це, швидше за все, Title():
func Title(s []byte) []byte
func Title(string s) string

Її висновок буде більш схожий на правду:
println(strings.Title("the count of monte cristo"))

// Output: The Count Of Monte Cristo

Маппінг рун
Є ще один спосіб заміни даних в слайсах байт і рядках — функція Map():
func Map(mapping func(r rune) rune, s []byte) []byte
func Map(mapping func(r rune) rune, string s) string

Ця функція дозволяє вказати свою функцію для перевірки і заміни кожної руни. Якщо чесно, я поняття не мав про цю функцію, поки не почав писати цей пост, тому ніякої особистої історії використання не можу тут розповісти.
Функції об'єднання і розділення
Досить часто доводиться працювати з рядками, що містять символи, за якими рядок потрібно розбивати. Наприклад, шляхи в UNIX об'єднані двокрапками, а формат CSV це, по суті, просто дані, записані через кому.
Розбиття рядків
Для простого розбиття слайсів або підрядків, у нас є Split()-функції:
func Split(s, sep []byte) [][]byte
func SplitAfter(s, sep []byte) [][]byte
func SplitAfterN(s, sep []byte, int n) [][]byte
func SplitN(s, sep []byte, int n) [][]byte

func Split(s, sep string) []string
func SplitAfter(s, sep string) []string
func SplitAfterN(s, sep string, int n) []string
func SplitN(s, sep string, int n) []string

Ці функції розбивають рядок або слайс байт згідно з роздільником і повертають їх у вигляді декількох слайсів або підрядків. After()-функції включають і сам роздільник в подстроках, а N()-функції обмежують кількість повернутих розділень:
strings.Split("a:b:c", ":") // ["a", "b", "с"]
strings.SplitAfter("a:b:c", ":") // ["a:", "b:", "с"]
strings.SplitN("a:b:c", ":", 2) // ["a", "b:c"]

Розбиття рядків є дуже частою операцією, але зазвичай це відбувається в контексті роботи з файлом у форматі CSV або UNIX-шляхів. Для таких випадків я використовую пакети encoding/csv і path відповідно.
Розбиття за категоріями
Іноді вам знадобиться вказувати роздільники у вигляді набору рун, а не серії рун. Найкращим прикладом тут буде розбиття слів прогалин різної довжини. Просто викликавши Split() з пропуском в якості роздільника, ви отримаєте на виході порожні підрядка, якщо на вході є кілька пробілів поспіль. Замість цього, використовуйте функцію Fields():
func Fields(s []byte) [][]byte

Вона трактує послідовні прогалини, як один роздільник:
strings.Fields("hello world") // ["привіт", "world"]
strings.Split("hello world", " ") // ["привіт", "", "", "world"]

Функція Fields() це простий врапперов навколо іншої функції — FieldsFunc(), яка дозволяє вказати довільну функцію для перевірки рун на роздільник:
func FieldsFunc(s []byte, f func(rune) bool) [][]byte

Об'єднання рядків
Інша операція, яка часто використовується при роботі з даними — це об'єднання слайсів і рядків. Для цього є функція Join():
func Join(s [][]byte, sep []byte) []byte
func Join(a []string, sep string) string

Одна з помилок, яку я зустрічав, полягає в тому, що розробники намагаються реалізувати об'єднання рядків вручну і пишуть щось на кшталт:
var output string
for i, s := range a {
output += s
if i < len(a) - 1 {
output += ","
}
}
return output

Проблема з цим кодом у тому, що в ньому відбувається дуже багато аллокаций пам'яті. Так як рядки можна буде змінити, кожна ітерація створює новий рядок. Функція strings.Join() ж використовує слайс байт в якості буфера і конвертує в рядок в самому кінці. Це мінімізує кількість аллокаций пам'яті.
Різні функції
Є дві функції, які я не зміг однозначно віднести до якої-небудь категорії, тому вони тут внизу. Перша функція Repeat() дозволяє створити рядок з повторюваних елементів. Чесно, єдиний раз, коли я її використовував, щоб створити лінію, що розділяє висновок у терміналі:
println(strings.Repeat("-", 80))

Інша функція — Runes() повертає слайс рун в рядку або слайсе байт, интерпретированном як UTF-8. Ніколи не використовував цю функцію, так як цикл for по рядку робить рівно те ж саме, без зайвих аллокаций.
Висновок
Слайсы байт і рядки це фундаментальні примітиви в Go. Вони є поданням байт або рун в пам'яті. Пакети bytes і strings надають велику кількість допоміжних функцій, а також адаптери для io.Reader і io.Writer інтерфейсів.
Досить легко випустити з уваги багато з цих корисних функцій із-за великого розміру API цих пакетів, але, я сподіваюся, що цей пост допоміг вам познайомитися з цими пакетами і дізнатися про ті можливості, які вони дають.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.