А как же строки?

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

Это экспериментальная заметка. Я буду признателен за любой фидбек.

Все правки, очепятки и откровенные ошибки отправляйте на электронный адрес rokkerruslan@protonmail.com, я исправлю это. Также можно внести правки самостоятельно через Pull Request. Буду рад. Я стараюсь не уходить слишком глубоко в детали и не распыляться, чтобы сохранить целостность и темп рассказа. Подробности вынесены в сноски: если какой-то пункт показался неочевидным, загляните туда (правда, иногда они запутывают ещё больше). Там же ссылки на дополнительные источники1.

Для кого эта статья? Для программистов на Go, которые хотят лучше понять, как работают встроенные типы данных, а именно строки и слайсы байт (далее просто слайсы).

Я рекомендую экспериментировать с примерами самостоятельно до момента полного их понимания.

Вполне вероятно, что вывод примеров и команд будет зависеть от версии компилятора и операционной системы. Примеры из частей 1 и 2 я запускал на:

go env GOVERSION GOARCH GOOS
go1.19
amd64
linux

а вывод примеров из части 3 снят на macOS (go1.19, darwin/arm64).

С введением всё.

Часть 1. Поствведение

Преобразование одних данных в другие — это одна из самых распространённых задач в программировании. Пример, с которого мы начнём, не будет отличаться оригинальностью или вычурностью. Он вполне банален, вы, скорее всего, не раз делали это самостоятельно. Есть список байт, мы получили его из некоего источника, нужно преобразовать массив в число. Последовательности байт будут содержать закодированные utf-8, целые, неотрицательные, 64-битные числа в десятичной системе счисления.

package main

func main() {
	inputs := [][]byte{
		{0x31, 0x32, 0x33}, // 1 2 3
		{0x34, 0x35, 0x36}, // 4 5 6
	}
	for i := range inputs {
		println(string(inputs[i])) // Нам нужно преобразовать в число значение inputs[i].
	}
}

// go run main.go
// 123
// 456

Структура данных, представляющая последовательность байт, в языке Go — это Array и построенный поверх него Slice. Длина массива фиксирована и входит в его тип, а длина слайса может меняться в процессе работы программы: в пределах ёмкости, а когда её не хватает, append переносит данные в новый массив. Некоторые примеры требуют изменения длины, поэтому мы используем именно слайс. С типами входных данных определились, едем дальше.

Алгоритм. На каждой итерации цикла копируем из источника данные в заранее выделенное место, обрабатываем их, преобразуя в число, далее проводим вычисления с этим числом. В первом приближении решение будет выглядеть так2:

// allocate buf
// loop
	n, err := source.Read(buf[:])
	// ...
	v, err := strconv.ParseUint(buf[:n], 10, 64)
	// use v

Всё хорошо, только этот код не компилируется — (playground). Всё дело в несовпадении типа аргумента buf[:n] и типа параметра функции ParseUint, byte-slice и string соответственно. Функции пакета strconv работают со строками, а не со слайсами. На это намекает даже название: strconv расшифруем как строка, конвертируй.

Для того чтобы понять, что делать дальше, нужно погуглить ref/spec по ключевым словам slice, string, conversion. И в Go есть механизм, позволяющий преобразовать значение одного типа в значение другого, называется он — Conversions. Синтаксис одинаков для всех пар типов: имя типа, выражение в скобках — Type(Expression) (если тип начинается с *, его самого тоже придётся взять в скобки: (*T)(x)). Правило, по которому происходит конверсия, отличается от пары к паре (если такая конверсия вообще разрешена). Необходимое нам правило есть в языке:

Converting a slice of bytes to a string type yields a string whose successive bytes are the elements of the slice.

Конверсия слайса в строку даёт строку, байты которой — это элементы слайса. Даём байты — получаем строку. То, что нужно, воспользуемся конверсией:

- v, err := strconv.ParseUint(buf[:n], 10, 64)
+ v, err := strconv.ParseUint(string(buf[:n]), 10, 64)

Да, теперь компилируется. Это корректный и быстрый код. А быстрый ли? С точки зрения производственной среды — да, он ведь не светится, как светодиодные игрушки на новогодней ёлке, в отчёте профилировщика. Бенчмарков сферических примеров в вакууме я намеренно не привожу. Оптимизировать стоит не по результатам бенчмарков и уж точно не потому, что «эта конструкция может работать неэффективно», а только по отчётам pprof, снятым с работающего приложения.

Но вопрос остаётся. А что, если вы всё же увидели этот блок кода в профилировщике? Насколько быстрый это код? Делает ли он лишние вычисления? Что он вообще делает?

Вернёмся и прочитаем ещё раз:

Converting a slice of bytes to a string type yields a string whose successive bytes are the elements of the slice.

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

Пока мы не можем предполагать, насколько сложной будет реализация и какую часть на себя берёт компилятор, а какую — runtime3. Если вспомнить, что слайс — изменяемая структура данных, а строка — нет, то возникает вопрос — как превратить изменяемую в неизменяемую?

Если зажать клавишу Ctrl и кликнуть на ParseUint, мы попадём4 на реализацию функции ParseUint. Но точно такой же клик на слово string приводит нас в файл builtin.go, документация которого говорит, что:

The items documented here are not actually in package builtin

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

Один из вариантов для Go — это запуск команды GOOS=linux GOARCH=amd64 go build -gcflags=-S5. Команда выводит много лишней для нас информации. Интересует нас только вызов функции strconv.ParseUint и вычисление её аргументов, так как строка — один из них.

Подготовка аргументов в вызов функции:

	// strconv.ParseUint(string(buf[:n]), 10, 64)

	CALL	runtime.slicebytetostring ; После вызова регистры AX/BX будут содержать строку.
	MOVL	$10, CX ; Положить число 10 в регистр CX
	MOVL	$64, DI ; Положить число 64 в регистр DI
	CALL	strconv.ParseUint

Обратить внимание нужно только на вызов функции runtime.slicebytetostring. Именно эта функция конвертирует байт слайс в строку6.

Это уже то, что можно найти в исходном коде. Без некоторых лишних деталей её реализация выглядит так7:

func slicebytetostring(buf *tmpBuf, ptr *byte, n int) (str string) {
	var p unsafe.Pointer
	if buf != nil && n <= len(buf) {
		p = unsafe.Pointer(buf)
	} else {
		p = mallocgc(uintptr(n), nil, false)
	}
	stringStructOf(&str).str = p
	stringStructOf(&str).len = n
	memmove(p, unsafe.Pointer(ptr), uintptr(n))
	return
}

Сначала функция проверяет, передали ли ей буфер под содержимое строки. Если нет или его длины недостаточно, runtime выделит место под строку в куче. Что же мы видим?

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

Мы всё равно её конструируем, выделяем память (в некоторых случаях), копируем содержимое слайса в место, выделенное под строку (всегда, кроме строк длиной 0 и 1: для них у runtime есть заготовки). И так на каждой итерации. Нагружаем runtime бесполезной работой: аллокатору нужно процессорное время, а отслеживание выделенных объектов и их освобождение сборщиком мусора тоже тратит ресурсы CPU.

Часть 2. Что такое строки и что такое байты

И ассемблер, и рантайм. Энто сложно. Прекращай!
А меж тем сказке — далеко до развязки!..

Мы удовлетворяем требование типа параметра функции strconv.ParseUint, и более нам не нужно значение типа string. Отсюда вытекает наше предположение: а можем ли мы попробовать не вызывать функцию slicebytetostring? Не использовать синтаксическую конструкцию string(X). Как ещё можно преобразовать один тип в другой?

Мы уже знаем, что строки — это просто последовательности байт. Значит, при создании строки не нужно проводить сложных вычислений, например, проверять, что байты — это валидный utf-8. Это, как минимум в теории, делает возможным преобразование значений с минимальными потерями в производительности.

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

Для начала посмотрим их представление в коде языка. Объявления типов находятся в пакете runtime:

// runtime/slice.go
type slice struct {
	array unsafe.Pointer
	len   int
	cap   int
}

// runtime/string.go
type stringStruct struct {
	str unsafe.Pointer
	len int
}

Слайс — это структура с тремя полями — unsafe.Pointer, int, int. Тип unsafe.Pointer в свою очередь — это указатель на произвольный тип, что такое этот произвольный тип тут не важно, нам достаточно, что это указатель:

type Pointer *ArbitraryType // type ArbitraryType int

Указатель на 64-битной системе равен 8 байтам. Два int-а это ещё 16 байт. Итого 24 байта на один слайс. Строка — это два поля, также указатель на данные — unsafe.Pointer и длина строки int всего 16 байт, ёмкости у строк нет.

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

Наш полигон:

func foobar () {
	a := "foobar"
	b := []byte(string("foobar"))

	// ...
}

Ниже представлена открытая сессия delve отладчика. В локальном окружении функции есть две переменные a и b, a строка, b — это слайс (byte — это алиас типа uint8). Для просмотра локальных переменных есть команда locals:

(dlv) locals
  a = "foobar"
  b = []uint8 len: 6, cap: 6, [...]

Значение строки нам показывают, значение слайса мы не видим, указывается только длина и ёмкость. Не очень понятное представление. Неясно, что лежит в памяти. Локальное окружение функции, а точнее сказать кадр стека, — тоже объект. Выполнив команду frame 0, мы увидим его визуализацию:

(dlv) frame 0
> main.foobar() ./main.go:9 (hits goroutine(1):1 total:1) (PC: 0x45fa02)
Frame 0: ./main.go:9 (PC: 45fa02)
     4:	func foobar() {
     5:		a := "foobar"
     6:		b := []byte(string("foobar"))
     7:	
     8:		// ...
=>   9:		println(a, b)
    10:	}
    11:	
    12:	func main() {
    13:		foobar()
    14:	}

Никакой нужной нам информации нет. Можно вычислить адрес кадра стека и после вычленить оттуда значения a и b, но намного проще получить адреса переменных в памяти и инспектировать уже её.

Адреса переменных можно получить так:

(dlv) print unsafe.Pointer(&a)
  (unsafe.Pointer)(0xc000052720)

(dlv) print unsafe.Pointer(&b)
  (unsafe.Pointer)(0xc000052730)

Так как мы уже видели выше, строка — это указатель и int. И указатель, и int на 64-битной системе равны восьми байтам. Восемь плюс восемь будет 16.

Значит, чтобы вычитать значение строки, нужно прочитать 16 байт, начиная с адреса переменной a с помощью команды examinemem:

(dlv) examinemem -fmt hex -size 1 -count 16 0xc000052720
  0xc000052720:   0x68   0xf6   0x46   0x00   0x00   0x00   0x00   0x00   
  0xc000052728:   0x06   0x00   0x00   0x00   0x00   0x00   0x00   0x00

Моя машина — little-endian8 машина. По младшему адресу 0xc000052720 хранится младший байт указателя на строку — это 0x68, по адресу старшему 0xc000052727 старший байт указателя — 0x00. По адресу 0xc000052728 находится младший байт значения длины — 0x06, по адресу 0xc00005272f — старший байт поля len — 0x00. Чтобы прочитать их по-человечески, нужно выписать байты в обратном порядке, без пробелов и разделителей: мы привыкли, что старший разряд стоит слева. Или можно попросить delve показать значения целиком. Размер значения при этом нужно указать самостоятельно: оба поля по 8 байт, поэтому просим 2 значения по 8 байт:

(dlv) examinemem -fmt hex -size 8 -count 2 0xc000052720
  0xc000052720:   0x000000000046f668   0x0000000000000006

Первые 8 байт — это указатель на строку, по нему тоже можно пройтись и посмотреть, что там лежит:

(dlv) examinemem -fmt hex -size 1 -count 6 0x000000000046f668
  0x46f668:   0x66   0x6f   0x6f   0x62   0x61   0x72 // f o o b a r -> foobar

Судя по документации, на момент написания delve не поддерживал формат char или что-то вроде того, так что декодируйте в уме. Начиная с delve 1.24 можно попросить examinemem -fmt raw -count 6 0x000000000046f668 — байты выведутся как есть, то есть foobar.

Так, хорошо, со строками разобрались. А что из себя представляет байт слайс?

(dlv) examinemem -fmt hex -size 1 -count 24 0xc000052730
  0xc000052730:   0x12   0x27   0x05   0x00   0xc0   0x00   0x00   0x00   
  0xc000052738:   0x06   0x00   0x00   0x00   0x00   0x00   0x00   0x00   
  0xc000052740:   0x06   0x00   0x00   0x00   0x00   0x00   0x00   0x00 

Так, первые 16 байт — это тоже адрес и длина. По адресу 0xc000052740 хранится ёмкость слайса.

А если с этого адреса прочитать только первые 16 байт, два поля по 8? Вы можете ответить на вопрос: это строка лежит или слайс байт?

(dlv) examinemem -fmt hex -size 8 -count 2 0xc000052730
  0xc000052730:   0x000000c000052712   0x0000000000000006

Всё это наводит на мысль, что мы можем представить слайс как строку. Если попытаться обмануть компилятор, сказать — ты сейчас обращаешься к строке, — подсунув при этом ему участок памяти, где лежит слайс, он точно ничего не заметит. Сломать систему типов может быть непросто. Начинать, как и всегда, стоит с документации. В этот раз искать по ключевым словам type system и violate.

В самом конце спецификации (это, видимо, чтобы подольше не находили) читаем:

The built-in package unsafe, known to the compiler and accessible through the
import path "unsafe", provides facilities for low-level programming including
operations that violate the type system.

О, да. Кажется, нарушение системы типов — это и есть те заветные слова, что мы ищем. Следующие параграфы проясняют как именно можно её (систему типов) поломать:

A Pointer is a pointer type but a Pointer value may not be dereferenced. Any
pointer or value of underlying type uintptr can be converted to a type of
underlying type Pointer and vice versa.

То есть любой указатель можно преобразовать в тип, underlying type которого — Pointer. А underlying type самого Pointer — тоже Pointer, значит, в него самого тоже можно. Наш алгоритм преобразования будет такой:

По шагам в коде:

buf := []byte{'H', 'e', 'l', 'l'}
pointerToByteSlice := unsafe.Pointer(&buf)
pointerToString := (*string)(pointerToByteSlice)
str := *pointerToString
fmt.Printf("val=%q type=%T\n", str, str) // val="Hell" type=string

Код, который генерирует компилятор, читает строку так же, как слайс: первые 8 байт — адрес, по которому лежат байты нашей строки, следующие 8 байт — длина. У слайса на тех же местах лежат ровно те же поля. Как мы видели в реализации функции slicebytetostring, содержимое слайса байт не проходит никакой обработки и не может завершиться неудачей10.

Однострочная версия:

buf := []byte{'1', '2', '3', '4'}
v, err := strconv.ParseUint(*(*string)(unsafe.Pointer(&buf)), 10, 64)
check(err)

fmt.Printf("type=%T val=%v", v, v) // type=uint64 val=1234

А без unsafe можно? Нет. Правила конверсии указателя на слайс в указатель на строку не существует. Как-то так:

(*string)(&buf)                 // cannot convert &buf (value of type *[]byte) to type *string
(*string)(unsafe.Pointer(&buf)) // This is fine 🔥

Что мы получаем в итоге? Вызов функции slicebytetostring, потенциальная аллокация и копирование заменены взятием адреса и разыменованием, а в машинном коде это просто пересылка слов в памяти, например:

  MOVQ  32(SP),  AX     ; Загрузить из стека (SP+32) указатель на байты слайса
  MOVQ  AX,      24(SP) ; Записать его в стек (SP+24) — в поле указателя строки

Этого точно не будет в отчёте pprof.

Часть 3. А как же строки?

Где-то на собеседовании…

— А как же строки, строки? Руслан.
— Какие ещё строки?
— Ты перечислял неизменяемые типы, почему не упомянул строки?
— А это из другой сказки.

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

Strings are immutable: once created, it is impossible to change the contents of a string.

Может быть, те строки, что мы строим из слайса, не настоящие? Попытаемся пойти обратной дорогой и начать вот с этих самых неизменяемых строк. Например, что насчёт литерала строки?

here := "Hello, Go!"
here[0] = 1 // cannot assign to here[0] (value of type byte)

И правда. Изменить строку не даёт уже компилятор.

Ты когда-нибудь Segmentation Fault на Go видел? И я не видел, а он — есть11:

Мы уже научились конвертировать слайсы в строки, что если действовать по аналогии? Создадим слайс, совместно использующий нижележащий массив вместе со строкой. Компилятор же не будет против изменения слайса?

package main

import "unsafe"

func main() {
	s := "Hello, Go!"
	b := *(*[]byte)(unsafe.Pointer(&s))
	b[0] = 'X' // unexpected fault address 0x100ebfe77
}

Компилятор код пропустил, но программа упала с фатальной ошибкой уже при исполнении. Это вполне способно разбудить вас ночью, но программисты не спят — идём дальше:

package main

import (
	"unsafe"
	"fmt"
)

func main() {
	s := string([]byte("Hello, Go!")) // Кто в Text Segment не спрятался, я не виноват.
	b := *(*[]byte)(unsafe.Pointer(&s))
	b[0] = 'X'

	fmt.Println(s) // Xello, Go!
}

Это работает, Гарольд. Получается, что не все строки такие уж неизменяемые: некоторые из них изменить можно (Playground). Изменяемы те, что лежат в изменяемой памяти — в куче или на стеке, неизменяемы те, что лежат в памяти только для чтения, которая загружается из исполняемого файла вместе с кодом программы12.

Конструкция []byte(<str>) заставляет компилятор выделить в изменяемой памяти процесса массив длиной в строку и скопировать туда её байты (из сегмента неизменяемой памяти). Для константной строки, как в примере, компилятор делает это сам, для остальных вызывает runtime.stringtoslicebyte. Преобразование результата в строку тоже происходит уже во время выполнения программы, и runtime-у нужно сконструировать строку из слайса, поэтому она уже не может быть помещена в сегмент неизменяемой памяти.

Версия для собеседований. Запомните или запишите:

package main

import (
	"unsafe"
	"fmt"
	"strings"
)

func main() {
	buf := strings.Builder{}
	buf.WriteString("Go strings are immutable")

	out := buf.String()

	doit(out) // Очевидно, ничего плохого со строкой не сделает.

	fmt.Println(out) // Go strings are mutable
}

func doit(s string) {
	b := *(*[]byte)(unsafe.Pointer(&s))

	for i := 0; i < 7; i++ {
		b[15+i] = b[17+i]
	}

	b[len(b)-2] = ' '
	b[len(b)-1] = ' '
}

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

package main

import (
	"unsafe"
	"fmt"
)

func main() {
	here := "Hello "
	here = string([]byte(here))

	out := *(*[]byte)(unsafe.Pointer(&here))

	fmt.Printf("%d %d\n", len(out), cap(out)) // 6 1374390628136
}

Длина равна шести. Ожидаемо. Ёмкость равна… Один миллион… не, один миллиард… — очень много. Шести не равно. Ответы 0, 6 и 42 пояснения не требуют, но 1374390628136?

Мы не создаём []byte явно. Когда мы говорим, что по адресу, к примеру, 0xff0000, лежит байт слайс, то cap(out) просто прочитает 8 байт по адресу 0xff0000 плюс 0x10. Но этот участок памяти заголовку уже не принадлежит. Если заголовок строки находился на стеке функции, то после заголовка может лежать другая переменная. Если заголовок был выделен в куче, то участок после заголовка строки может принадлежать совершенно другой переменной, выделенной ранее где-то в программе.

// Заголовок строки в памяти.
0xff0000 0x000001400001c0a0 // указатель на байты строки (в куче)
0xff0008 0x0000000000000006 // длина
// Дальше лежит уже что-то чужое. По виду это указатель на объект в куче:
// 0x1400010af28 = 1374390628136. Его мы и прочитаем как ёмкость.
0xff0010 0x000001400010af28

Мы не контролируем значение ёмкости получившегося слайса. Значение может быть совершенно случайным. В зависимости от того, что уже успел навыделять аллокатор. Если вы запустите код на своей машине, вывод может быть другим, но заранее сказать, каким он будет, нельзя.

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

package main

import (
	"unsafe"
	"fmt"
)

func main() {
	here := "Hello "
	here = string([]byte(here))

	out := *(*[]byte)(unsafe.Pointer(&here))
	out = out[:100]
	fmt.Println(string(out)) // "Hello  @�!@HelloO@"
}

Ха, участок памяти, где лежит строка “Hello “, тоже не изолированный. За ним следующие ячейки памяти, в которых тоже хранятся значения других переменных программы.

Представим, что клиент определяет, сколько он может ещё получить данных. Распространённая практика13. Ограничение всё равно есть, чтобы не перегрузить сервер. Мы пишем надёжное ПО?

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	here := "Hello "
	here = string([]byte(here))

	func() {
		secretparol := string([]byte("lolkekcheburek")) // Секретное преобразование для секретного пароля
		secretparol += ""
	}()

	out := *(*[]byte)(unsafe.Pointer(&here))

	fmt.Printf("%d %d\n", len(out), cap(out))

	fmt.Println(string(out))

	const clientCanReceive = 1000000 // Могу получить ещё 1000000. Присылай!
	out = out[:clientCanReceive]
	fmt.Println(string(out))
}

Полный вывод:

6 1374389815144
Hello
Hello �!@("@(�  @Hello Ў        @Ў      @�zp@�sP:�s
@$@@�!@`)��     @��     @�      @�      @`      @�      @ !     @@��!@�&@@@�@�@��
          ����r �����AQ�"���hdA�I8��hd�r        ��hd�r  ���cr�9�Z�@����r       �����AQ�"���hdA�I8��hd�r        ��hd�r  ���cr�9�
@p
@1374389815144.lolkekcheburekhG@6 Hello // ТУТ КАЖЕТСЯ ЕСТЬ ПАРОЛЬ
89815144
Hello ��px`
@f�f��0�`@0`@�@ �@
���l�r@@``@ �@�`@�`@x�@ H�@ a@
u*���(*�@�@�@�@0�@
@�@               @

  xY�\�@�`@`�P@/Users/rokkerruslan/w/secret.maxfilesperprocIp // Так, а это что такое? Мак?
@

Это вывод с macOS. На другой машине ёмкость может оказаться меньше миллиона — тогда сработает проверка границ, и программа упадёт с slice bounds out of range. Проверка на месте, просто сверяется она с поддельной ёмкостью.

В воздухе давно витает слово «уязвимость». Не смотрите, что в выводе много лишней информации: пароли и другие секреты обычно выделяются на фоне остального мусора, их легко заметить. А некоторые строки вообще по структуре можно искать, примерно так выглядят все AccessKeyID от AWS — AKIAJOPAGANGNAMSTYLE.

Если вам кажется, что это нереалистичный сценарий, то всё-таки кажется. У этого типа ошибок своё название есть — Buffer Over-read.

Подумаем. Программа может:

Часть 4. «Правильный» способ

Один из лучших способов повысить свой уровень — учиться у старших.

Компилятор — это не только инструмент для сборки программ на Go, но и огромный набор примеров кода. Если вы хотите изучить некий алгоритм, исходники Go (компилятора и runtime) — неплохая точка входа. Нам же даже искать не надо, мы уже видели нужный нам код.

Помним, что есть функция slicebytetostring. Она выделяет место в памяти для нижележащего массива (или берёт переданный буфер), а потом копирует туда элементы исходного массива. Но это только часть, а что с самой структурой данных заголовка строки?

Посмотрим на реализацию ещё раз:

func slicebytetostring(buf *tmpBuf, ptr *byte, n int) (str string) {
	var p unsafe.Pointer
	if buf != nil && n <= len(buf) {
		p = unsafe.Pointer(buf)
	} else {
		p = mallocgc(uintptr(n), nil, false)
	}
	stringStructOf(&str).str = p
	stringStructOf(&str).len = n
	memmove(p, unsafe.Pointer(ptr), uintptr(n))
	return
}

Заголовок строки создаётся внутри функции slicebytetostring. Поля str и len заполняются явно, новыми значениями. Значение поля str — это указатель на выделенный (или переданный) участок памяти, значение поля len — это длина участка памяти.

Слово «правильный» звучит так себе, лучше скажем более безопасный. Это способ преобразовать строку в слайс и обратно без аллокации и копирования нижележащего массива. Но будьте внимательны: спать нельзя даже тут, и в его использовании можно накосячить14.

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

Используем подход slicebytetostring. Структуры, описывающие заголовки слайса и строки, есть в пакете reflect:

// Конверсия из слайса в строку.
func btos(in []byte) (out string) {
	slice := (*reflect.SliceHeader)(unsafe.Pointer(&in))
	str := (*reflect.StringHeader)(unsafe.Pointer(&out))
	str.Data = slice.Data
	str.Len = slice.Len

	return
}

// Конверсия из строки в слайс байт.
func stob(in string) (out []byte) {
	slice := (*reflect.SliceHeader)(unsafe.Pointer(&out))
	str := (*reflect.StringHeader)(unsafe.Pointer(&in))
	slice.Data = str.Data
	slice.Len = str.Len
	slice.Cap = str.Len

	return
}

Концептуально подход не отличается: unsafe остаётся, а формально остаётся и нарушение системы типов, ведь reflect.SliceHeader — это не runtime.slice. Если их представление в памяти разойдётся, всё сломается. Пока это маловероятно, но документация reflect прямо предупреждает, что такое возможно.

Но мы больше не интерпретируем блок памяти от заголовка слайса как блок памяти заголовка строки. Мы создаём новый заголовок, строки или слайса, и заполняем его поля руками. Устойчивее ли этот подход к ошибкам? Для конверсии строки в слайс — да: ёмкость мы задаём явно, а не читаем из памяти за заголовком строки, как в части 3. Для обратной конверсии разницы почти нет: заголовок строки совпадает с началом заголовка слайса, поэтому *(*string)(unsafe.Pointer(&buf)) тоже корректен — именно так с Go 1.10 по 1.19 был написан strings.Builder.String(). Стоит оговориться: вы создаёте новый объект, и где он окажется, на стеке или в куче, решаете не вы, а компилятор15.

Обновление. В Go 1.20 в пакете unsafe появились функции String, StringData и SliceData (unsafe.Slice есть ещё с Go 1.17), а в Go 1.21 reflect.SliceHeader и reflect.StringHeader объявлены устаревшими. Поле Data у них имеет тип uintptr, и сборщик мусора не считает его указателем. Новые функции работают с типизированным указателем *byte, поэтому сегодня те же конверсии пишутся так:

// Конверсия из слайса в строку.
func btos(in []byte) string {
	return unsafe.String(unsafe.SliceData(in), len(in))
}

// Конверсия из строки в слайс байт.
func stob(in string) []byte {
	return unsafe.Slice(unsafe.StringData(in), len(in))
}

Ограничения прежние: байты, переданные в btos, нельзя менять, пока существует полученная строка, а слайс, полученный из stob, нельзя менять вообще — строка может лежать в памяти только для чтения, как литерал из части 3.

Сложность реального процесса и изначальное представление строк как неизменяемых типов приводят к тому, что безопасной функции конверсии (без выделения памяти и копирования) в стандартной библиотеке Go нет — только unsafe-версия из обновления выше. Со слов разработчиков, так задумано.

Отдельная проблема в том, что компилятор Go не подталкивает вас к правильному решению16. Для него все способы одинаковы, пока код компилируется, а, как мы видели выше, «компилируется» не значит «работает». И даже «работает» не значит «корректно работает». Корректную работу должен обеспечить программист. Не надейтесь, что кот будет работать просто так.

Заключение

Заключения нет. Делитесь и распространяйте статью. С вас лайк, подписка и, конечно же, не забудьте нажать на колокольчик дабы не пропустить новые…


  1. Кроме первой. Но я больше так не буду, das verspreche ich. Нажмите на обратную стрелку, расположенную сразу за текстом, чтобы вернуться на место сноски в тексте. На мобильных устройствах это должно работать ещё лучше, попробуйте сделать жест, которым вы обычно говорите браузеру — назад!. ↩︎

  2. Разбиение потока на отдельные сообщения или, по-другому, — message framing, мы оставим за бортом. Предположим, что одна операция чтения (вызов Read) возвращает одно сообщение, которое в свою очередь содержит одно число и оно точно поместится в отведённый буфер. ↩︎

  3. Для некоторых конверсий реализация будет простой. Если конвертировать число int64 в uint64, то для runtime не будет никакой работы. Компилятору достаточно генерировать ассемблерные инструкции для работы со знаковыми числами до конвертации и инструкции для работы с беззнаковыми числами после этой конвертации. Например, оператор >> должен генерировать инструкцию арифметического сдвига SAR — shift arithmetic right, то есть с расширением знака, при работе со знаковым числом. А при работе с беззнаковым, сдвиг должен быть логическим SHR — shift right. Поэтому для сдвига переменной типа int64 компилятор использует SAR. После конверсии значения в uint64 использует SHR. Содержимое памяти в процессе конверсии не изменяется, поэтому рантайм языка Go в этой процедуре принимать участие не будет и можно сказать, что этот процесс не имеет накладных расходов во время работы программы. ↩︎

  4. Если вы, как и я, из поколения IDE. А если нет, то и сами знаете, как найти. ↩︎

  5. Команда build -gcflags=-S печатает код, сгенерированный компилятором, в синтаксисе Go-ассемблера — это почти один в один итоговые машинные инструкции. Мы не будем подробно на нём останавливаться. Его знание не играет роли для понимания статьи, MOVL $1, AX — это копирование значения один в регистр AX, и этого достаточно. Если же вы хотите более подробно изучить его, начать стоит с A Quick Guide to Go’s Assembler. ↩︎

  6. По первым отзывам на статью я понял, что углубляться в ассемблер — плохо. Читаемость падает. В результате я сильно сократил ассемблерные вставки и их объяснение. В этом случае не нужно знать, как именно строка становится аргументом. Но в сноске поговорим подробнее. В Go 1.17 изменили способ передачи аргументов в функции. Как видите, теперь на amd64 они передаются не через стек, а через регистры (ячейки хранения внутри самого процессора). Регистры раздаются по машинным словам, слева направо по сигнатуре функции: первое слово попадает в AX, второе — в BX, третье и четвёртое — в CX и DI (дальше идут SI и R8–R11). С возвращаемыми значениями ситуация аналогична. slicebytetostring возвращает только одно значение типа string. Но строка в Go — это два поля: указатель на последовательность байт и значение длины строки. Поэтому, чтобы передать одну строку, нужно ровно два регистра. Итак, вызов функции приведёт к тому, что в регистре AX будет указатель на последовательность байт новой строки, а в регистре BX её длина. Функция strconv.ParseUint требует ещё два аргумента, основание системы счисления и размер числа в битах. Строка уже находится в регистрах AX/BX (как удобно), а вот CX и DI нужно заполнить. Это неполное описание процесса. Подробнее в статье Calling Convention. ↩︎

  7. В версии 1.20 код немного изменён, но не принципиально. Мы не будем разбирать новую версию, я уверен, после прочтения статьи вы без труда сами проанализируете изменение. ↩︎

  8. Порядок байт. Тема небольшая и не маленькая. И полностью не покрыть в сноске и ссылку оставлять не хочется. Есть числа, которые занимают больше чем 1 байт, а значит больше чем 1 ячейку в памяти. Например, int16 — это два байта, то есть оно занимает две ячейки в памяти. Возьмём значение 1 типа int16 — 0x0001. Старший байт равен 0, младший — 1. У каждой ячейки есть адрес. Например, по адресу 0xffff00a7 будем хранить байт 0x00, а в следующем адресе 0xffff00a8 будем хранить 0x01. Так, или наоборот? Наоборот будет красивше, да, давайте в 0xffff00a7 хранить младший байт — 0x01, а в старшем адресе старший байт — 0x00. Чтобы определить чётность или нечётность числа, достаточно прочитать только один первый байт из памяти, удобно же, не правда ли? Если без шуток, разницы принципиальной нет. Продолжение чтения — Endian Comparison ↩︎

  9. Разыменование копирует 16 байт — указатель и длину — в новую переменную str. Заголовки у buf и str теперь разные, общий у них только массив байт. Вопрос на самостоятельное изучение: что напечатает fmt.Println(str), если после этого выполнить buf[0] = 'Y'? ↩︎

  10. Текст. Текст или строки, обычно, чутка более сложные сущности, чем просто последовательности байт. Текст — это закодированные символы, и, очевидно, не всё множество последовательностей байт это валидный текст. Поэтому некоторые языки, при преобразовании массива байт в строку могут производить более сложные операции. Так же этот процесс не всегда успешен, например, если мы не смогли распознать последовательность байт как символы в той или иной кодировке. Язык Go поступает проще — строки могут содержать произвольный набор байт. Поэтому вся эта мишура с валидацией ложится на плечи программистов, но только если им это нужно. То есть те строки, что есть в Go, — это не совсем те строки, что вы можете видеть, например, в Rust. Нет строк — нет проблем! ↩︎

  11. На самом деле, сам сигнал SIGSEGV в Go встречается часто — при разыменовании nil указателя, когда забыли инициализировать поле структуры или что-то в этом роде. Но runtime превращает его в обычную панику invalid memory address or nil pointer dereference, которую можно перехватить через recover; строчку [signal SIGSEGV: segmentation violation ...] вы наверняка видели в её выводе. А вот при обращении к другим «плохим» адресам, как в примере ниже, паники не будет: runtime сразу завершит программу с fatal error: fault. ↩︎

  12. Виды памяти в программе. Для нас достаточно, что для процесса (в котором исполняется наша программа) существует неизменяемая область памяти и изменяемая. В неизменяемой памяти хранится, например, сама программа, литералы строк. В изменяемой памяти хранятся переменные, стеки горутин, любые значения, выделенные в куче. Это сложная тема. Детали сильно зависят от операционной системы. Я не думаю, что есть один материал, описывающий всё досконально. Стартовая точка для linux-based систем — Anatomy of a Program in Memory. Более глубокое погружение — Understanding the Memory Layout of Linux Executables. ↩︎

  13. Например, в протоколе TCP есть окно приёма: одна сторона сообщает другой, сколько ещё байт она может принять. ↩︎

  14. Нельзя явно создавать экземпляры этих структур, только конвертировать из строк/байт-слайс. Более подробно в документации к типу unsafe.Pointer, пункт номер 6. ↩︎

  15. Так как Go является языком со сборщиком мусора, для программиста нет принципиальной разницы, где хранить то или иное значение. Посмотрим на C: вызывая функцию malloc, вы явно просите runtime (да, даже у C он есть) выделить вам память в куче. Такой подход нужен программам, которые жёстко контролируют память. Например, встраиваемым устройствам — там памяти просто мало. Или ядрам операционных систем — гарантии, которые ОС даёт пользовательским процессам, она не может обеспечить для самой себя. Но в Go вы не можете предполагать, где компилятор разместит значение. ↩︎

  16. Код конверсии не выдаёт ошибок на проверке go vet. ↩︎