[Перевод] Разбираемся в 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(s string) *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, j int) 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("I ", "") // 6
len("I ") // 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, prefix string) 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(s string, cutset string) string
Она удаляет все руны из набора cutset с обеих сторон — с начала и конца строки. Также можно удалять только с начала, или только с конца, используя TrimLeft () и TrimRight () соответсвенно.
Но чаще всего используются более конкретные варианты удаления — удаление пробелов, для этого есть функция TrimSpace ():
func TrimSpace(s []byte) []byte
func TrimSpace(s string) string
Вы можете подумать, что удаления с cutset-ом равным »\n\r» может быть достаточно, но TrimSpace () умеет удалять также символы пробелов, определённые в Unicode. Сюда входят не только пробелы, перевод строки или символ табуляции, но и такие нестандартные символы как «thin space» или «hair space».
TrimSpace (), на самом деле, всего лишь обёртка над TrimFunc (), которая определяет символы, которые будут использоваться для удаления:
func TrimSpace(s string) string {
return TrimFunc(s, unicode.IsSpace)
}
Таким образом можно очень просто создать свою функцию, которая будет удалять, скажем, только пробелы в конце строки:
TrimRightFunc(s, unicode.IsSpace)
В заключение, если вы хотите удалить не символы, а конкретную подстроку слева или справа, то для этого есть функции TrimPrefix () и TrimSuffix ():
func TrimPrefix(s, prefix []byte) []byte
func TrimPrefix(s, prefix string) 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, n int) []byte
func Replace(s, old, new string, n int) 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(s string) string
func ToLower(s []byte) []byte
func ToLower(s string) string
Но, в некоторых языках правила регистров отличаются от общепринятых. К примеру, в турецком языке, i в верхнем регистре выглядит как İ. Для таких специальных случаев, есть специальные версии этих функций:
strings.ToUpperSpecial(unicode.TurkishCase, "i")
Далее, у нас есть ещё заглавный регистр и функция ToTitle ():
func ToTitle(s []byte) []byte
func ToTitle(s string) 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(s string) 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, s string) string
Эта функция позволяет указать свою функцию для проверки и замены каждой руны. Если честно, я понятия не имел об этой функции, пока не начал писать этот пост, поэтому никакой личной истории использования не могу тут поведать.
Функции объединения и разделения
Довольно часто приходится работать со строками, содержащими разделители, по которым строку нужно разбивать. Например, пути в UNIX объединены двоеточиями, а формат CSV это, по сути, просто данные, записанные через запятую.
Разбиение строк
Для простого разбиения слайсов или подстрок, у нас есть Split ()-функции:
func Split(s, sep []byte) [][]byte
func SplitAfter(s, sep []byte) [][]byte
func SplitAfterN(s, sep []byte, n int) [][]byte
func SplitN(s, sep []byte, n int) [][]byte
func Split(s, sep string) []string
func SplitAfter(s, sep string) []string
func SplitAfterN(s, sep string, n int) []string
func SplitN(s, sep string, n int) []string
Эти функции разбивают строку или слайс байт согласно разделителю и возвращают их в виде нескольких слайсов или подстрок. After ()-функции включают и сам разделитель в подстроках, а N ()-функции ограничивают количество возвращаемых разделений:
strings.Split("a:b:c", ":") // ["a", "b", "c"]
strings.SplitAfter("a:b:c", ":") // ["a:", "b:", "c"]
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") // ["hello", "world"]
strings.Split("hello world", " ") // ["hello", "", "", "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 этих пакетов, но, я надеюсь, что этот пост помог вам познакомиться с этими пакетами и узнать о тех возможностях, которые они дают.