Внутри Go: Строки

Давайте немного поговорим про строки, про их внутреннее устройство и про то, как с ним лучше работать, не тратя слишком много ресурсов.

Во время написания статьи использовалась версия 1.20 и 64-битная система. Будет много кода на Go, ассемблера и бенчмарков.

Люблю бенчмарки!

Память

Как известно, строка это примитивный неизменямый тип, который можно представить с помощью структуры runtime.StringHeader (а на деле runtime.stringStruct).

Первое, что нужно сделать, так это понять, как устроена строка и где расположено её содержимое. У нас есть уже упомянутая структура StringHeader, которая состоит из двух полей: Data и Len. Первое это указатель (тип uintptr) на начало байтового массива, второе это длина этого участка в байтах:

1type StringHeader struct {
2  Data uintptr // указывает на кучу
3  Len  int     // длина строки в байтах
4}

Чтобы превратить string в StringHeader нам нужно совершить небольшую манипуляцию с указателями:

1str := "hello"
2hdr := *(*reflect.StringHeader)(unsafe.Pointer(&str))
3fmt.Printf("%#v\n", hdr) // &reflect.StringHeader{Data:0x49a510, Len:5}

Для большего понимания лучше разобьём код на шаги:

1str := "hello"
2strPtr := &str
3rawPtr := unsafe.Pointer(strPtr)
4hdrPtr := (*reflect.StringHeader)(rawPtr)
5hdr := *hdrPtr
  1. объявляем обычную строку str;
  2. сохраняем указатель на строку str в strPtr, получаем тип *string;
  3. конвертируем указатель strPtr типа *string в сырой указатель и сохраняем в rawPtr;
  4. кастим сырой указатель rawPtr в указатель типа *reflect.StringHeader и сохраняем в hdrPtr;
  5. разыменовываем hdrPtr и получаем значение типа reflect.StringHeader в переменной hdr.

Выглядит жутко. Но теперь мы можем получить адрес непосредственно самой строки на куче через hdr.Data. Почему мы не можем просто взять &str и не заниматься этими фокусами с указателями? Чтобы выяснить разницу, давайте напишем такой код:

1str := "hello"
2hdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
3strPtr := &str
4dataPtr := hdr.Data
5
6fmt.Printf("%p\n", strPtr)  // 0x0009e210
7fmt.Printf("%x\n", dataPtr) // 0x0049a510

Теперь видно, что strPtr и dataPtr указывают на разные адреса. Это потому что strPtr указывает на заголовок строки (тип StringHeader), а dataPtr на сами данные.

Обычно заголовок размещается на стеке, а данные где-нибудь на куче (но есть исключения). Визуально это можно представить как-то так:

1str := "hello" // эту строку можно редактировать
stack01234567
...
0x0009e210str.Data = 0x0049a510
0x0009e218str.Len  = 5
...
heap01234567
...
0x0049a510hello...
0x0049a518........
...

Такой подход с использованием заголовка отдельно от самих данных, позволяет передавать строки из одной функции в другую с минимальными накладными расходами. Нам требуется скопировать всего 16 байт, чтобы передать строку произвольной длины. Давайте напишем бенчмарк и убедимся в сказанном, создадим строки длиной в 16 и 64 байта и будем передавать их в какую-нибудь функцию:

string_passing_test.go
 1package main
 2
 3import "testing"
 4
 5var result = 0
 6
 7//go:noinline
 8func strlen(s string) int {
 9	return len(s)
10}
11
12func BenchmarkPassString16(b *testing.B) {
13	var s16 = "0123456789abcdef"
14	var sum int
15	for i := 0; i < b.N; i++ {
16		sum += strlen(s16)
17	}
18
19	result += sum
20}
21
22func BenchmarkPassString64(b *testing.B) {
23	var s64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
24	var sum int
25	for i := 0; i < b.N; i++ {
26		sum += strlen(s64)
27	}
28
29	result += sum
30}

Если бы строки копировались, то разумно предположить, что вызов с 64-байтной строкой будет происходить примерно в 4 раза медленнее, чем с 16-байтной, но на деле мы получаем вот такой результат:

1goos: linux
2goarch: amd64
3cpu: AMD Ryzen 9 5950X 16-Core Processor
4BenchmarkPassString16-32    	952152790	         1.313 ns/op
5BenchmarkPassString64-32    	922256157	         1.254 ns/op

Из этого можно сделать вывод, что время вызова функции strlen не зависит от размера строки. Со слайсами, кстати, это работает точно также, а вот с массивами уже нет, они копируются целиком. Но об этом мы поговорим как-нибудь в другой раз.

Давайте, для полной уверенности, взглянем на код, сгенерированный компилятором. Скажем в терминале go tool compile -S -N string_passing_test.go и получим примерно такой вывод (лишний код удален):

1main.BenchmarkPassString16 STEXT size=179 args=0x8 locals=0x30 funcid=0x0 align=0x0
2; ...
3  0x004b 00075 (string_passing_test.go:16)  MOVQ  main.s16(SB), AX
4  0x0052 00082 (string_passing_test.go:16)  MOVQ  main.s16+8(SB), BX
5  0x0059 00089 (string_passing_test.go:16)  CALL  main.strlen(SB)
6; ...
7; ... для 64-байтных строк код аналогичен

Из ассемблера видно, что копируется только заголовок, который не зависит от размера самой строки, а сами данные никуда не перекладываются.

Ну а чтобы не усложнять себе жизнь, можно просто измерить размер:

1s16 := "hello"
2fmt.Printf("len=%d sizeof=%d\n", len(s16), unsafe.Sizeof(s16)) // len=5 sizeof=16
3
4s64 := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
5fmt.Printf("len=%d sizeof=%d\n", len(s64), unsafe.Sizeof(s64)) // len=64 sizeof=16

Unicode, UTF-8, руны и байты

0x0b
0x0b
0x0b
0x0b

UTF-8 это динамическая многобайтовая кодировка, в которой символы имеют размер от 1 до 4 байтов включительно, в зависимости от своей группы. Например, латиница из стандарта ASCII кодируется всего одним байтом, а для кириллицы нужно уже два. Иероглифы занимают до 4-х байт на символ. Принцип кодирования неплохо описан в Википедии.

В Go строковые литералы содержат валидный UTF-8 текст, если не используются escape-последовательности. Но несмотря на это, мы не можем получить отдельный символ по его индексу. Приведу простой пример:

1str := "привет"
2char := str[0]
3fmt.Printf("%T %v", char, char)

Тип uint8, а значение 208. Но 208 это не код буквы “п”, у неё код 1087. Напомню, что кириллица занимает по 2 байта на символ, поэтому нам надо взять str[0] и str[1]:

1str := "привет"
2char := int32(str[0]&0b00011111)<<6 + int32(str[1]&0b00111111)
3fmt.Printf("%T %v\n", char, char)

Здесь мы берём 2 байта из строки, накладываем на них маску, чтобы избавиться от маркеров UTF-8 и склеиваем в одно число, смещая на 6 битов влево первое значение. Смещение на 6 битов нужно, потому что для второго байта в двухбайтовом символе есть только 6 значащих битов. В итоге получаем как и ожидалось: int32 1087.

Слишком сложно, чтобы получить код первого символа. Нужно знать про устройство UTF-8 и возиться с побитовыми смещениями. К счастью, есть способ проще — привести строку к слайсу рун:

1str := "привет"
2runes := []rune(str)
3fmt.Printf("%T %v\n", runes[0], runes[0])

Получаем те же int32 и код 1087 с помощью рун. Руна это специальный тип, который представляет любой UTF-8 символ. Размер руны всегда 4 байта, независимо от группы символа. Но кастить строку в руны не бесплатно, придётся раскодировать строку на отдельные символы и выделить дополнительно len(str) * sizeof(rune) байт памяти. Напишем бенчмарк:

str_to_rune_test.go
 1package main
 2
 3import "testing"
 4
 5var str = "привет мир"
 6var runes []rune
 7var bytes []byte
 8
 9func BenchmarkCastRunes(b *testing.B) {
10  b.ReportAllocs()
11  for i := 0; i < b.N; i++ {
12    runes = []rune(str)
13  }
14}
15
16func BenchmarkCastBytes(b *testing.B) {
17  b.ReportAllocs()
18  for i := 0; i < b.N; i++ {
19    bytes = []byte(str)
20  }
21}
1goos: linux
2goarch: amd64
3cpu: AMD Ryzen 9 5950X 16-Core Processor
4BenchmarkCastRunes-32  16908456  116.8 ns/op   48 B/op  1 allocs/op
5BenchmarkCastBytes-32  33305972   33.50 ns/op  24 B/op  1 allocs/op

Из бенчмарка видно, что каст к слайсу рун медленнее в несколько раз и приходится выделять больше памяти, на данные: 40 байт выделяются для случая с []rune и 19 для случая с []byte (значения выровнены до размера блока, который может выделить аллокатор: 48 и 24 байта, соответственно).

Бывают случаи, когда нужно перебрать все символы строки, например, чтобы найти какой-то конкретный символ, его позицию или убедиться, что символы входят в какой-либо диапазон. Для этого случая лучше использовать for-range по строке без предварительного преобразования:

str_range_test.go
 1package main
 2
 3import "testing"
 4
 5var str = "привет мир"
 6
 7func BenchmarkStringRange(b *testing.B) {
 8  var sum int32
 9  for i := 0; i < b.N; i++ {
10    for _, r := range str {
11      if r == 'р' {
12        sum += r
13      }
14    }
15  }
16}
17
18func BenchmarkRunesRange(b *testing.B) {
19  var sum int32
20  for i := 0; i < b.N; i++ {
21    runes := []rune(str)
22    for _, r := range runes {
23      if r == 'р' {
24        sum += r
25      }
26    }
27  }
28}
1goos: linux
2goarch: amd64
3cpu: AMD Ryzen 9 5950X 16-Core Processor
4BenchmarkStringRange-32    	55610866	        20.25 ns/op
5BenchmarkRunesRange-32     	22873388	        53.13 ns/op

Цикл for-range сразу по строке работает почти в 2.5 раза быстрее, чем вариант с предварительным преобразованием. Но почему так? Ведь for-range по строке даёт нам те же руны, как если бы мы сначала преобразовали строку к слайсу рун. Давайте разбираться, посмотрим на сгенерированный ассемблерный код:

 1main.BenchmarkStringRange STEXT size=303 args=0x8 locals=0x58 funcid=0x0 align=0x0
 2  ; ...
 3  0x00bd 00189 (str_range_test.go:10) MOVQ  main..autotmp_5+56(SP), CX
 4  0x00c2 00194 (str_range_test.go:10) MOVQ  main..autotmp_4+64(SP), AX
 5  0x00c7 00199 (str_range_test.go:10) MOVQ  main..autotmp_4+72(SP), BX
 6  0x00cc 00204 (str_range_test.go:10) CALL  runtime.decoderune(SB)
 7  0x00d1 00209 (str_range_test.go:10) MOVL  AX, main..autotmp_7+36(SP)
 8  0x00d5 00213 (str_range_test.go:10) MOVQ  BX, main..autotmp_5+56(SP)
 9  ; ...
10main.BenchmarkRunesRange STEXT size=325 args=0x8 locals=0xf0 funcid=0x0 align=0x0
11  ; ...
12  0x005c 00092 (str_range_test.go:21) MOVQ  main.str(SB), BX
13  0x0063 00099 (str_range_test.go:21) MOVQ  main.str+8(SB), CX
14  0x006a 00106 (str_range_test.go:21) LEAQ  main..autotmp_6+56(SP), AX
15  0x006f 00111 (str_range_test.go:21) CALL  runtime.stringtoslicerune(SB)
16  0x0074 00116 (str_range_test.go:21) MOVQ  AX, main.runes+184(SP)
17  0x007c 00124 (str_range_test.go:21) MOVQ  BX, main.runes+192(SP)
18  0x0084 00132 (str_range_test.go:21) MOVQ  CX, main.runes+200(SP)
19  ; ...

При итерации по строке происходит вызов функции runtime.decoderune. Она читает слайс, начиная с определённого смещения, которое постоянно увеличивается и возвращает руну и её длину в байтах, чтобы рантайм мог правильно передвинуть счётчик внутреннего цикла. Во втором случае, когда мы сначала преобразовываем к []rune, а потом перебираем значения, то вызывается runtime.stringtoslicerune, которая предварительно заполняет слайс рун значениями и уже потом идёт перебор.

Неизменяемость

В Go строки не дублируются в рамках программы и несколько одинаковых строковых литералов будут указывать на одну и ту же область памяти. Это полезно, потому что мы не тратим лишнюю память на дубликаты:

 1package main
 2
 3import (
 4	"fmt"
 5	"reflect"
 6	"unsafe"
 7)
 8
 9func straddr(str string) uintptr {
10	return (*reflect.StringHeader)(unsafe.Pointer(&str)).Data
11}
12
13func test1() {
14	str := "hello"
15	fmt.Printf("0x%08x\n", straddr(str)) // 0x004996bc
16}
17func test2() {
18	str := "hello"
19	fmt.Printf("0x%08x\n", straddr(str)) // 0x004996bc
20}
21
22func main() {
23	test1()
24	test2()
25}

Есть для этого ещё одна причина: как уже говорилось, строка это набор символов юникода, в котором у каждого символа может быть разный размер в байтах. Из-за этого нельзя взять и заменить один символ на другой по какому-то индексу, при несовпадении размеров старого и нового символа мы рискуем задеть его соседей. Да и компилятор нам этого не позволит. Что же тогда делать? Тут есть несколько вариантов:

Во-первых, можно создать новую строку с нужными заменами, для этого есть достаточное количество инструментов: конкатенация, strings.Replace, strings.Builder и bytes.Buffer, fmt.Sprint и fmt.Sprintf. Все их мы рассмотрим чуть дальше.

Во-вторых можно привести string к []rune, заменить нужный символ по индексу, а потом привести обратно к string:

 1package main
 2
 3import (
 4	"fmt"
 5)
 6
 7func setsymrunes(str string, idx int, to rune) string {
 8	runes := []rune(str)
 9	runes[idx] = to
10	return string(runes)
11}
12
13func main() {
14	s := "привет мир"
15	fmt.Printf("%s\n", s) // "привет мир"
16	s = setsymrunes(s, 6, ',')
17	fmt.Printf("%s\n", s) // "привет,мир"
18}

Неплохой и вполне рабочий способ. В большинстве случаев этого достаточно.

Ну а в-третьих, можно прибегнуть к черной магии указателей и обмануть компилятор. Заранее должен предупредить, что этот способ стоит использовать с большой осторожностью, а лучше вообще не использовать, особенно в production — можно повредить данные или упасть с ошибкой сегментации. Но мы же здесь не для того, чтобы осторожничать, так что давайте попробуем:

Опасность — моё второе имя. Первое имя — Минимальная
 1package main
 2
 3import (
 4	"fmt"
 5	"reflect"
 6	"strings"
 7	"unsafe"
 8)
 9
10func setsymptr(str string, idx int, to byte) {
11	shdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
12	bhdr := reflect.SliceHeader{
13		Data: shdr.Data,
14		Len:  shdr.Len,
15		Cap:  shdr.Len,
16	}
17	(*(*[]byte)(unsafe.Pointer(&bhdr)))[idx] = to
18}
19
20func main() {
21	s := strings.Clone("привет мир")
22	fmt.Printf("%s\n", s) // "привет мир"
23	setsymptr(s, 12, ',')
24	fmt.Printf("%s\n", s) // "привет,мир"
25}

В целом код выглядит знакомо, мы используем трюк из начала статьи для приведения строки к StringHeader, затем создаём SliceHeader с указателем на строку, и, наконец, приводим всё это к слайсу и изменяем в нём отдельный байт. Также стоит обратить внимание на несколько отличий от реализации с слайсом рун. Первое, это копирование строки с помощью strings.Clone. Так нужно, чтобы строка была создана в рантайме и была помещена в область кучи, которая доступна для записи, можно использовать любой другой способ создания строки или взять уже созданную. Если использовать обычный строковый литерал, то на некоторых системах можем получить ошибку сегментации из-за того, что пытаемся писать в readonly-область памяти. Второе отличие, это индекс, по которому заменяем символ. Мы используем кириллицу, которая, напомню, занимает по 2 байта на символ, соответственно слово “привет” длиной в 6 символов занимает 12 байт. Ну и самое главное отличие это то, что не создаётся новая строка, а изменяется старая.

А теперь к бенчмаркам!

setsym_test.go
 1package main
 2
 3import (
 4	"reflect"
 5	"strings"
 6	"testing"
 7	"unsafe"
 8)
 9
10var str = "привет мир"
11
12func setsymrunes(str string, idx int, to rune) string {
13	runes := []rune(str)
14	runes[idx] = to
15	return string(runes)
16}
17
18func setsymptr(str string, idx int, to byte) {
19	shdr := (*reflect.StringHeader)(unsafe.Pointer(&str))
20	bhdr := reflect.SliceHeader{
21		Data: shdr.Data,
22		Len:  shdr.Len,
23		Cap:  shdr.Len,
24	}
25	(*(*[]byte)(unsafe.Pointer(&bhdr)))[idx] = to
26}
27
28func BenchmarkRunes(b *testing.B) {
29	var r string
30	for i := 0; i < b.N; i++ {
31		s := setsymrunes(str, 6, ',')
32		if s != "привет,мир" {
33			panic("fail")
34		}
35		r = s
36	}
37	_ = r
38}
39
40func BenchmarkPointer(b *testing.B) {
41	var r string
42	for i := 0; i < b.N; i++ {
43		s := strings.Clone(str)
44		setsymptr(s, 12, ',')
45		if s != "привет,мир" {
46			panic("fail")
47		}
48		r = s
49	}
50	_ = r
51}
1goos: linux
2goarch: amd64
3cpu: AMD Ryzen 9 5950X 16-Core Processor            
4BenchmarkRunes-32      	 8450078	       184.3 ns/op
5BenchmarkPointer-32    	39423016	        32.39 ns/op

Результат был и так очевиден. В методе с указателем мы не занимаемся лишней работой — не переводим строку в слайс рун, а только меняем один байт в памяти, а основная задержка здесь вызвана клонированием строки.

Ещё раз напомню: не изменяйте строку через сырые указатели в production коде, если не понимаете до конца как это работает и на что может повлиять.

Создание строк

Чтобы создать строку не нужно использовать какие-то мудрёные конструкции и вызывать функции с кучей параметров. Достаточно обернуть “некавычки” в кавычки и вот она "строка". Далее с этой строкой можно производить любые манипуляции, например склеивать с другой такой же строкой.

Конкатенация

Самым очевидным и быстрым способом склеить строку из нескольких является конкатенация с помощью оператора “+”:

1"Привет, " + "мир" // "Привет, мир"
"яблоко" + "яблоко" != "два яблока"

Наверное, это самый распространённый и простой способ. Давайте напишем функцию, которая будет складывать переданные строки, чтобы посмотреть как конкатенация устроена внутри:

1package main
2
3func concat(str1, str2 string) string {
4  return str1 + str2
5}

Из неё будет сгенерирован такой ассемблерный код:

 1  0x0000 00000 (concat.go:3)  TEXT  main.concat(SB), NOSPLIT|ABIInternal, $64-32
 2  ; ... часть кода удалена
 3  0x0028 00040 (concat.go:4)  MOVQ  main.str1+72(SP), BX
 4  0x002d 00045 (concat.go:4)  MOVQ  main.str2+88(SP), DI
 5  0x0032 00050 (concat.go:4)  MOVQ  main.str1+80(SP), CX
 6  0x0037 00055 (concat.go:4)  MOVQ  main.str2+96(SP), SI
 7  0x003c 00060 (concat.go:4)  XORL  AX, AX
 8  0x003e 00062 (concat.go:4)  PCDATA  $1, $1
 9  0x003e 00062 (concat.go:4)  NOP
10  0x0040 00064 (concat.go:4)  CALL  runtime.concatstring2(SB)
11  0x0045 00069 (concat.go:4)  MOVQ  AX, main.~r0+40(SP)
12  0x004a 00074 (concat.go:4)  MOVQ  BX, main.~r0+48(SP)
13  0x004f 00079 (concat.go:4)  MOVQ  56(SP), BP
14  0x0054 00084 (concat.go:4)  ADDQ  $64, SP
15  0x0058 00088 (concat.go:4)  RET

Видно, что сначала заголовки строк помещаются в регистры (строки 3-6), которые используются для передачи аргументов, а затем происходит вызов функции runtime.concatstring2 (строка 10). Эта функция подготавливает параметры для runtime.concatstrings и передаёт управление ей (есть также concatstring3, concatstring4 и concatstring5). А вот runtime.concatstrings как раз уже занимается непосредственно склеиванием строк. Давайте взглянем на неё повнимательнее, я переведу док-комментарий и добавлю немного своих:

 1// concatstrings реализует конкатеницию строк в Go.
 2// Операнды передаются через слайс a.
 3// buf != nil значит, что результат не "убегает" на heap из
 4// вызывающей функции и результирующее значение достаточно
 5// маленькое (меньше 32 байт) и может быть помещено в buf.
 6func concatstrings(buf *tmpBuf, a []string) string {
 7  idx := 0
 8  l := 0
 9  count := 0
10  // здесь мы считаем общую длину результата
11  for i, x := range a {
12    n := len(x)
13    if n == 0 {
14      continue
15    }
16    // Как я понимаю, это защита от переполнения счётчика длины строки
17    // и значит, что результирующая строка слишком длинная,
18    // чтобы поместиться в память
19    // (надеюсь меня кто-нибудь поправит, если ошибаюсь)
20    if l+n < l {
21      throw("string concatenation too long")
22    }
23    l += n
24    count++
25    idx = i
26  }
27  if count == 0 {
28    return ""
29  }
30
31  // Если была передана только одна строка и она не находится на стеке
32  // или результат не "убегает" из стекового фрейма вызывающей
33  // функции (при buf != nil), то мы можем вернуть эту строку напрямую.
34  // stringDataOnStack проверяет находится ли содержимое строки на стеке.
35  if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
36    return a[idx]
37  }
38  // Внутри rawstringtmp происходит проверка на то, что buf != nil и
39  // помещается ли результат в buf.
40  // Если да, то мы не выделяем память под новую строку, а все данные
41  // складываются в buf.
42  s, b := rawstringtmp(buf, l)
43  for _, x := range a {
44    copy(b, x)
45    b = b[len(x):]
46  }
47  return s
48}

Под “убегает” (escape) я подразумеваю сохранение значения переменной на куче вместо стека.

Более подробно можно почитать здесь.

Пакет fmt

Часто можно встретить использование семейства *print/*printf функций для форматирования каких-либо значений. Эти функции имеют достаточно развесистый набор параметров для форматирования строк, но рассматривать их здесь мы не будем, для этого есть официальная документация. Стоит лишь упомянуть, что хоть эти функции мощные и удобные и позволяют быстро получить желаемый результат, но из их мощи также вытекает и побочный эффект — они достаточно медленные и на горячих участках их лучше избегать, например используя конкатенацию или билдеры.

Билдеры

Ещё одним способом создания строки является использование билдера (strings.Builder), который накапливает содержимое во внутреннем буфере для его преобразования в строку позднее.

1bld := strings.Builder{}
2bld.WriteString("привет")
3bld.WriteString(" мир")
4
5bld.String() // привет мир

Строковый билдер создаётся с нулевым внутренним буфером и растёт по мере добавления новых данных. Это не очень оптимально, я бы рекомендовал заранее подумать о размере внутреннего буфера билдера, чтобы избежать лишних аллокаций и перемещений данных.

 1package main
 2
 3import (
 4  "strings"
 5  "testing"
 6)
 7
 8func BenchmarkZeroBuff(b *testing.B) {
 9  b.ReportAllocs()
10
11  for i := 0; i < b.N; i++ {
12    bld := strings.Builder{}
13    for j := 0; j < 10; j++ {
14      bld.WriteString("hello")
15    }
16  }
17}
18
19func BenchmarkGrow(b *testing.B) {
20  b.ReportAllocs()
21
22  for i := 0; i < b.N; i++ {
23    bld := strings.Builder{}
24    bld.Grow(len("hello") * 10)
25    for j := 0; j < 10; j++ {
26      bld.WriteString("hello")
27    }
28  }
29}
1goos: linux
2goarch: amd64
3cpu: AMD Ryzen 9 5950X 16-Core Processor
4BenchmarkZeroBuff-32    13198950    132.20 ns/op   120 B/op    4 allocs/op
5BenchmarkGrow-32        24308022     56.13 ns/op    64 B/op    1 allocs/op

Для пустого буфера приходится сделать 3 лишних выделения памяти и несколько раз переместить данные с места на место. Для заранее выделенного буфера такого не происходит и в итоге код работает в 2.5 раза быстрее.

Кроме strings.Builder можно использовать bytes.Buffer, который в целом аналогичен, но дополнительно реализует интерфейсы io.Reader и io.Writer

Настоятельно рекомендую ознакомиться с пакетами strings и bytes, скорее всего это поможет сэкономить некоторое время при разработке.

Слайсы

Слайс строки это тоже строка. Он указывает он на ту же область памяти, в которой расположена оригинальная строка.

 1str := "привет"
 2hdr := *(*reflect.StringHeader)(unsafe.Pointer(&str))
 3fmt.Printf("0x%08x\n", hdr.Data) // 0x0049ca15
 4fmt.Printf("%d\n", hdr.Len)      // 12
 5
 6str1 := str[:5]
 7hdr1 := *(*reflect.StringHeader)(unsafe.Pointer(&str1))
 8fmt.Printf("0x%08x\n", hdr1.Data) // 0x0049ca15
 9fmt.Printf("%d\n", hdr1.Len)      // 5
10
11str2 := str[2:5]
12hdr2 := *(*reflect.StringHeader)(unsafe.Pointer(&str2))
13fmt.Printf("0x%08x\n", hdr2.Data) // 0x0049ca17
14fmt.Printf("%d\n", hdr2.Len)      // 3

Поле Data для str и str1 указывает на один и тот же адрес, различаются они только длиной. Для str2 начальный адрес отличается, но незначительно, всего на 2 байта, которые мы пропустили в слайсе.

Кстати, если слайс строки это тоже строка, а строка у нас содержит двухбайтовые символы, что произойдёт, если мы возьмём слайс по длине, не кратной размеру символа? Ответ прост, мы получим невалидный символ:

1"привет"[:5] // пр�
Я � Юникод

Для решения этой проблемы можно либо привести строку к слайсу рун и работать уже с ним:

1rstr := []rune("привет")
2string(rstr[:5]) // приве

Или воспользоваться пакетом golang.org/x/exp/utf8string:

1str := utf8string.NewString("привет")
2str.Slice(0, 5) // приве

Если знаете другие способы, то свяжитесь со мной, я добавлю их в статью.

Сравнение строк

Ну и последняя тема, которую хочется рассмотреть, это сравнение строк между собой и проверка на пустоту.

Начнём со сравнения. Как мы помним из начала, заголовок строки состоит из двух полей: Data и Len, которых может быть вполне достаточно для понимания является строка той же самой или нет: если заголовок содержит те же значения, значит и строка та же — логично; если отличается длина, то тут всё понятно, строки равны быть не могут; но если отличается только адрес, а длина совпадает, то тогда придётся начать сравнивать строки посимвольно до первого отличия.

Напишем бенчмарк, чтобы проверить это:

cmp_test.go
 1package main
 2
 3import (
 4  "strings"
 5  "testing"
 6)
 7
 8var (
 9  // оригинальная строка
10  str1 = "привет"
11  // копия оригинальной строки, расположенная по другоу адресу
12  str2 string
13  // строка, с совпадающей длиной, но отличающимся содержимым
14  str3 = "превед"
15  // слайс от оригинальной строки, отличается длиной
16  str4 = str1[:6]
17)
18
19func init() {
20  str2 = strings.Clone(str1)
21}
22
23//go:noinline
24func cmp(s1, s2 string) bool {
25  return s1 == s2
26}
27
28func BenchmarkSameString(b *testing.B) {
29  for i := 0; i < b.N; i++ {
30    _ = cmp(str1, str1)
31  }
32}
33
34func BenchmarkCloneString(b *testing.B) {
35  for i := 0; i < b.N; i++ {
36    _ = cmp(str1, str2)
37  }
38}
39
40func BenchmarkOtherString(b *testing.B) {
41  for i := 0; i < b.N; i++ {
42    _ = cmp(str1, str3)
43  }
44}
45
46func BenchmarkShortString(b *testing.B) {
47  for i := 0; i < b.N; i++ {
48    _ = cmp(str1, str4)
49  }
50}
1goos: linux
2goarch: amd64
3cpu: AMD Ryzen 9 5950X 16-Core Processor
4BenchmarkSameString-32      443450349          2.564 ns/op
5BenchmarkCloneString-32     305124512          4.006 ns/op
6BenchmarkOtherString-32     342467617          3.501 ns/op
7BenchmarkShortString-32     624654148          1.777 ns/op

Наши догадки подтверждаются бенчмарком. Но всегда лучше заглянуть в исходник. Для начала посмотрим код, генерируемый компилятором для функции cmp:

 1  ; ...
 2  0x0000 00000 (cmp_test.go:20)  TEXT  main.cmp(SB), ABIInternal, $40-32
 3  ; ...
 4  0x0014 00020 (cmp_test.go:20)  FUNCDATA  $0, gclocals·iilYh2zWk/RieCMyRG2Y4w==(SB)
 5  0x0014 00020 (cmp_test.go:20)  FUNCDATA  $1, gclocals·J5F+7Qw7O7ve2QcWC7DpeQ==(SB)
 6  0x0014 00020 (cmp_test.go:20)  FUNCDATA  $5, main.cmp.arginfo1(SB)
 7  0x0014 00020 (cmp_test.go:20)  MOVQ  AX, main.s1+48(SP)
 8  0x0019 00025 (cmp_test.go:20)  MOVQ  BX, main.s1+56(SP)
 9  0x001e 00030 (cmp_test.go:20)  MOVQ  CX, main.s2+64(SP)
10  0x0023 00035 (cmp_test.go:20)  MOVQ  DI, main.s2+72(SP)
11  0x0028 00040 (cmp_test.go:20)  MOVB  $0, main.~r0+31(SP)
12  0x002d 00045 (cmp_test.go:21)  MOVQ  main.s2+72(SP), DX
13  0x0032 00050 (cmp_test.go:21)  CMPQ  main.s1+56(SP), DX
14  0x0037 00055 (cmp_test.go:21)  SETEQ DL
15  0x003a 00058 (cmp_test.go:21)  JEQ 62
16  0x003c 00060 (cmp_test.go:21)  JMP 84
17  0x003e 00062 (cmp_test.go:21)  MOVQ  main.s1+48(SP), AX
18  0x0043 00067 (cmp_test.go:21)  MOVQ  main.s2+64(SP), BX
19  0x0048 00072 (cmp_test.go:21)  MOVQ  main.s1+56(SP), CX
20  0x004d 00077 (cmp_test.go:21)  PCDATA  $1, $1
21  0x004d 00077 (cmp_test.go:21)  CALL  runtime.memequal(SB)
22  0x0052 00082 (cmp_test.go:21)  JMP 88
23  0x0054 00084 (cmp_test.go:21)  MOVL  DX, AX
24  0x0056 00086 (cmp_test.go:21)  JMP 88
25  0x0058 00088 (cmp_test.go:21)  MOVB  AL, main.~r0+31(SP)
26  0x005c 00092 (cmp_test.go:21)  MOVQ  32(SP), BP
27  0x0061 00097 (cmp_test.go:21)  ADDQ  $40, SP
28  0x0065 00101 (cmp_test.go:21)  RET
29  ; ...

Видно, что сначала сравнивается размер двух строк, если он одинаковый, то выполняется переход на строку 17 (JEQ 62), где для дальнейшего сравнения вызывается функция runtime.memequal, которая является заглушкой для набора архитектурно-зависимых ассемблерных реализаций, в нашем случае это internal/bytealg/equal_amd64.s. Взглянем на неё:

 1; memequal(a, b unsafe.Pointer, size uintptr) bool
 2TEXT runtime·memequal<ABIInternal>(SB),NOSPLIT,$0-25
 3  ; AX = a    (want in SI)
 4  ; BX = b    (want in DI)
 5  ; CX = size (want in BX)
 6  CMPQ  AX, BX
 7  JNE neq
 8  MOVQ  $1, AX  // return 1
 9  RET
10neq:
11  MOVQ  AX, SI
12  MOVQ  BX, DI
13  MOVQ  CX, BX
14  JMP memeqbody<>(SB)

В регистрах AX и BX содержатся адреса наших строк и далее они сравниваются (строка 6), при несовпадении адресов (строка 7) происходит переход на метку neq и переход к функции memeqbody (строка 14), которая уже сравнивает непосредственно содержимое строк. Если же адреса равны, то перехода не будет и произойдёт установка значения 1 в регистр AX (что для нас значит true) и возврат в вызывающий код. Листинг memeqbody я приводить не буду, т.к. он достаточно большой, его можно найти на гитхабе и подробно изучить.

Хорошо, вроде бы разобрались с тем как строки сравниваются между собой. Но что происходит когда мы проверяем строку на пустоту?

Чаще всего я вижу 2 варианта этой проверки: str == "" и len(str) == 0, давайте разберём их подробнее, посмотрим что происходит в каждом случае и сравним их скорость.

Сначала сравнение с пустой строкой. Напишем небольшую функцию:

1func empty(s string) bool {
2  return s == ""
3}

И посмотрим во что она компилируется:

 1main.empty STEXT nosplit size=52 args=0x10 locals=0x10 funcid=0x0 align=0x0
 2  0x0000 00000 (main.go:3) TEXT  main.empty(SB), NOSPLIT|ABIInternal, $16-16
 3  0x0000 00000 (main.go:3) SUBQ  $16, SP
 4  0x0004 00004 (main.go:3) MOVQ  BP, 8(SP)
 5  0x0009 00009 (main.go:3) LEAQ  8(SP), BP
 6  0x000e 00014 (main.go:3) FUNCDATA  $0, gclocals·wgcWObbY2HYnK2SU/U22lA==(SB)
 7  0x000e 00014 (main.go:3) FUNCDATA  $1, gclocals·J5F+7Qw7O7ve2QcWC7DpeQ==(SB)
 8  0x000e 00014 (main.go:3) FUNCDATA  $5, main.empty.arginfo1(SB)
 9  0x000e 00014 (main.go:3) MOVQ  AX, main.s+24(SP)
10  0x0013 00019 (main.go:3) MOVQ  BX, main.s+32(SP)
11  0x0018 00024 (main.go:3) MOVB  $0, main.~r0+7(SP)
12  0x001d 00029 (main.go:4) CMPQ  main.s+32(SP), $0
13  0x0023 00035 (main.go:4) SETEQ AL
14  0x0026 00038 (main.go:4) MOVB  AL, main.~r0+7(SP)
15  0x002a 00042 (main.go:4) MOVQ  8(SP), BP
16  0x002f 00047 (main.go:4) ADDQ  $16, SP
17  0x0033 00051 (main.go:4) RET

Длина строки (полученная через регистр BX) попросту сравнивается с нулём. Здесь нет вызова runtime.memequal, т.к. сравнение с пустой строкой это специальный случай и компилятор понимает это.

Теперь перепишем нашу функцию empty для сравнения по длине:

1func empty(s string) bool {
2  return len(s) == 0
3}

И посмотрим во что она скомпилируется:

 1main.empty STEXT nosplit size=59 args=0x10 locals=0x18 funcid=0x0 align=0x0
 2  0x0000 00000 (main.go:3) TEXT  main.empty(SB), NOSPLIT|ABIInternal, $24-16
 3  0x0000 00000 (main.go:3) SUBQ  $24, SP
 4  0x0004 00004 (main.go:3) MOVQ  BP, 16(SP)
 5  0x0009 00009 (main.go:3) LEAQ  16(SP), BP
 6  0x000e 00014 (main.go:3) FUNCDATA  $0, gclocals·wgcWObbY2HYnK2SU/U22lA==(SB)
 7  0x000e 00014 (main.go:3) FUNCDATA  $1, gclocals·J5F+7Qw7O7ve2QcWC7DpeQ==(SB)
 8  0x000e 00014 (main.go:3) FUNCDATA  $5, main.empty.arginfo1(SB)
 9  0x000e 00014 (main.go:3) MOVQ  AX, main.s+32(SP)
10  0x0013 00019 (main.go:3) MOVQ  BX, main.s+40(SP)
11  0x0018 00024 (main.go:3) MOVB  $0, main.~r0+7(SP)
12  0x001d 00029 (main.go:4) MOVQ  main.s+40(SP), CX
13  0x0022 00034 (main.go:4) TESTQ CX, CX
14  0x0025 00037 (main.go:4) SETEQ AL
15  0x0028 00040 (main.go:4) MOVQ  CX, main..autotmp_2+8(SP)
16  0x002d 00045 (main.go:4) MOVB  AL, main.~r0+7(SP)
17  0x0031 00049 (main.go:4) MOVQ  16(SP), BP
18  0x0036 00054 (main.go:4) ADDQ  $24, SP
19  0x003a 00058 (main.go:4) RET

Получилось несколько больше инструкций и немного изменилась логика работы. Инструкция TESTQ выполняет побитовое “И” для своих операндов (поле Len из заголовка строки) и устанавливает флаг ZF (в процессоре), если в результате получился 0. Инструкция SETEQ смотрит на флаг ZF и устанавливает свой операнд в значение 1, если ZF равен 0. Затем этот результат возвращается в вызывающий код. Таким образом при нулевой длине строки мы в результате получаем true.

Различное количество инструкций как-то влияют на скорость? Пишем бенчмарк:

 1package main
 2
 3import (
 4  "testing"
 5)
 6
 7var (
 8  str = "привет"
 9)
10
11//go:nosplit
12//go:noinline
13func strempty(s string) bool {
14  return s == ""
15}
16
17//go:nosplit
18//go:noinline
19func strlen(s string) bool {
20  return len(s) == 0
21}
22
23func BenchmarkEmptyString(b *testing.B) {
24  for i := 0; i < b.N; i++ {
25    strempty(str)
26  }
27}
28
29func BenchmarkZeroLength(b *testing.B) {
30  for i := 0; i < b.N; i++ {
31    strlen(str)
32  }
33}
1goos: linux
2goarch: amd64
3cpu: AMD Ryzen 9 5950X 16-Core Processor
4BenchmarkEmptyString-32     917021476          1.320 ns/op
5BenchmarkZeroLength-32      828375831          1.283 ns/op

Сравнение с пустой строкой чуточку медленнее, чем сравнение длины с 0, но читается в коде оно проще, так что лично я бы выбрал именно вариант с str == "".

Итог

Работа со строками, несмотря на свою кажущуюся простоту, имеет достаточно нюансов и подводных камней, которые могут привести к замедлению кода, лишним выделениям памяти или даже к падению всей программы. Поэтому стоит понимать как код работает внутри и выбирать наиболее оптимальные и безопасные подходы.

Полезное чтение: