Простые правила, которые помогают мне писать на Go без побочных эффектов
- пятница, 14 июля 2023 г. в 00:00:22
Успел поработать с роботами, телекомом, поисковиками. В YADRO разрабатываю драйверы для OpenStack и систем хранения данных, модули для Ansible и еще много-много всего.
Роб Пайк сказал, что простое лучше, чем сложное. Я бы добавил: простое лучше, чем прикольное. Ведь Go спроектирован, чтобы писать программы в простом стиле.
Сегодня я хочу поговорить про такие, казалось бы, очевидные вещи, как функции, интерфейсы и методы. Их особенности в Go. И правила, которые я выработал, чтобы писать понятно для коллег, компьютера и себя в недалеком будущем.
Есть два способа получить результат работы функции. Можно сделать это через входные параметры, указатели. При этом мы можем менять передаваемые аргументы:
func double(a *int) {
*a = *a * 2
}
А можно пойти прямым путем и возвратить значение через return:
func double(a int) int {
return a * 2
}
Второй вариант часто надежнее. Функции, которые иногда меняют аргументы внезапно для программиста, — это не очень здорово. Подавая на вход неизменяемые аргументы и не используя в них глобальные переменные, мы получаем «чистые» функции. Это избавляет нас от неприятных сюрпризов. На «чистые» функции легко писать тесты, плюс, теперь мы можем легко и достаточно безопасно параллелить функции. А программу будет проще анализировать в потоке данных. Одни плюсы.
Возникает простое правило. Если мы не хотим модифицировать наш аргумент, то передаем его по значению. Если модифицируем, используем указатель.
Во многих языках для этого есть const, но в Go нет неизменяемых объектов, поэтому здесь const – это просто псевдоним какого-то литерала. И мы не можем с его помощью сказать компилятору: «Пожалуйста, не трогай то, что я сейчас напишу».
Что же дает Go на замену константности аргументов? Передачу аргументов по значению, как копии аргумента внутри функции. И хотя мы в принципе можем поменять такой аргумент, это будет более-менее безопасно, когда мы выйдем из функции. В итоге наша идеальная функция будет выглядеть примерно так:
func IdealFunction (value InputType) ReturnType {
Но надо помнить, что есть нюансы. Сложные структуры данных могут состоять как из простых типовых структур, так и из указателей. А все, что мы передаем через указатель, можно легко поменять. Плюс, большинство сложных типов, например, те же slice и map, будут мутабельны, потому что внутри содержат указатели.
Когда по-другому никак) Например, такая структура данных, как map, это по факту указатель на структуру. То есть как map не передавай...
Другой популярный сценарий — это работа с JSON. Например, мы берем строчку и хотим распаковать ее в структуру:
...
var ts TeamScore
input := `{“Team”: “Yadro”, “score”: 777}`
// ?? = json.Unmarshal([]byte(input)) не сработает
json.Unmarshal([]byte(input), &ts)
Если мы не передадим модифицируемый аргумент в Unmarshal, функция не сможет понять, что ей нужно делать на выходе: выдать TeamScore, вывести OK и 42. Здесь и пригодится указатель.
Когда у нас больше 100 байт данных. Я провел эксперимент, чтобы понять, насколько большие данные нужно передать в функцию для перехода от копирования значения в передачу по адресу.
Я взял книжку, состоящую из array-байтов, чтобы вся память принадлежала ей. Читал ее, передавая и по значению, и через звездочку. И постепенно увеличивал размер.
type Book struct {
Text [10]byte
}
func ReadBookByValue(book Book, n int) byte {...}
func ReadBookByPointer(book *Book, n int) byte {...}
Чтобы результат был честным, я покопался в директивах компилятора: исходно он оптимизирует маленькие функции, поэтому я запретил ему делать инлайнинг, то есть выдирать тело маленькой функции и подставлять его в вызов. Вот что получилось: вплоть до 100 байт копирование книжки по стеку практически было равно ее передаче по 8-байтовому указателю. А вот после стековая копия всё росла, и передавать по адресу стало явно выгоднее по времени.
Конечно, структуру, в которую будет вложен огромный array, еще надо постараться найти. Большинство используемых типов данных содержат в себе указатели на такие большие массивы, но не сам массив.
Мы можем скопировать объект из стека наверх, либо воспользоваться синтаксисом new и make для возврата указателя. Я снова взял книжки разного размера и провел эксперимент.
Для книжек небольшого объема явно выиграл объект. Потому что значение было сразу в стеке, а не в куче. Аллокации в куче обходятся очень дорого, потому что нам надо найти или освободить память.
Дальше оказалось, что вплоть до 10 мегабайт мы можем передавать объекты по стеку быстрее, чем делать через new и отдавать адрес.
Практически все можно передавать по стековому копированию: ведь придумать объект, который будет весить больше 10 мегабайт, сложно. А если мы захотим, вызывая функции, использовать указатели, можно сразу поместить все это в кучу и не париться со стеком, — то есть написать сразу то, что принимает клиент.
Переменные в куче обычно не зависят от функций, в которых они созданы, и могут жить неопределенно долго. Но плохо то, что на создание, поиск свободной памяти и ее очистку тратится существенное время.
Переменные в стеке живут только в своих функциях и, к сожалению, не такие долгожители. Зато мгновенное создаются и удаляются. Go ставит на такие переменные, потому что они позволяют языку быть быстрым.
Почему в примере с возвращением объекта все менялось на отметке в 10 мегабайт? Компилятор занимается так называемым анализом побегов (Escape Analysis). Поэтому он может переносить какие-то наши переменные из стека в кучу и наоборот. Вот здесь определены значения пределов, при которых он может держать что-то в стеке.
Например, когда мы объявляем переменную в стеке и возвращаем ее адрес, компилятор сразу разместит Car в куче:
type Car struct { }
func BuildCar() *Car {
car := Car{}
return &car
}
В целом, компилятор старается держать небольшие данные в стеке, а все остальное в куче. Например, если мы возьмем очень большую книжку, все сразу уйдет в кучу:
type Book struct {
text [20_000_000]byte
}
func GetBook() Book {
book := Book{}
return book
}
А если мы создадим объект до 64 килобайт через функцию new() и решим не возвращать его адрес, компилятор перенесет его в стек. Чтобы проверить это, я создавал книжки, начиная с 8-байтовой. И пока я не достиг 64 килобайт, у меня было 0 аллокаций в куче и все было очень быстро.
func BenchmarkBookInHeap(b *testing.B) {
for i := 0; i < b.N; i++ {
// Book size = 8 bytes, не хотим убегать
b := new(Book8)
_ = b
}
}
Как только я достигал 64 килобайт или больше, появлялись аллокации и все замедлялось.
Если мы возьмем приемник (первый аргумент) по указателю, то сможем менять объект.
Если там стоит значение, то метод имеет неизменяемый объект.
Приемники методов ведут себя как параметры функции, ведь по сути они и есть параметры, посыпанные синтаксическим сахаром. Большой приемник по значению — это много стекового копирования. Лучше использовать приемник указатель.
Если один метод в структуре изменяет объект, а другой — нет, мы все равно ставим приемник со звездочкой, чтобы это было удобнее читать:
type Car struct {
x, y float64
ok bool
}
func (c *Car) Move(x, y float64) { c.x, c.y = x, y; }
func (c *Car) CheckEngine() bool { return c.ok; }
Тем самым мы показываем, что объект изменяем в принципе, даже если какие-то методы его не изменяют.
Поэтому интерфейс стоит определять именно как контракт между вашей функцией, методом и аргументом, который она принимает. Нам без разницы, какой аргумент, лишь бы у него был нужный метод.
Интерфейсы в Go – это структура из двух указателей:
type iface struct {
tab *itab
// таблица функций (указатель на тип)
data unsafe.Pointer
// копия данных (приемник)
… }
Первый — это указатель на таблицу методов типа данных, на которые сейчас смотрит интерфейс. Второй — это копия данных, которая помогает нам сохранять иммутабельность в случаях, когда она нужна. Но если мы поместим сюда указатель на исходные данные, интерфейс будет приравнен к настоящему объекту, и мы можем его модифицировать.
С точки зрения производительности, у нас есть функция, в которой мы говорим, что аргумент — это интерфейс. И когда мы передаем аргументы в эту функцию, происходит копирование из объекта в этот интерфейс. Рассмотрим пример:
func BenchmarkValueIface(b *testing.B) {
var red Red
var iface Stringer
for i := 0; i < b.N; i++ {
iface = red
// iface = &green
_ = iface.String()
}
}
Если в каком-то объекте у нас приемник со звездочкой, мы приравниваем его к интерфейсу уже с амперсандом, и можем вызывать модифицирующие методы:
BenchmarkPointerIface-16 1.456 ns/op 0 B/op 0 allocs/op
Если же у нас немодифицируемый интерфейс, произойдет достаточно большая аллокация. Весь объект скопируется в кучу, это достаточно дорого:
BenchmarkValueInface-16 23.90 ns/op 16 B/op 1 allocs/op
Получается, немодифицируемые интерфейсы лучше не использовать часто.
Функции не дают передать указатель, где нужно значение, и наоборот. Для примера, попробуем подставлять различные варианты, с указателем и без:
..
func pointed(n *int) {
*n++
}
func pointless(n int) {
n++
}
То получим:
a := 99
pointed(&a)
pointed(a) // Нет!
pointless(a)
pointless(&a) // Нет!
Итого, нельзя передать указатель в функцию, которая хочет сам тип.
Все варианты ниже сработают:
type N struct { n int }
func (n *N) pointed() {
*n++
}
func (n N) pointless() {
n++
}
И выведут:
a := N{9}
(&a).pointed()
a.pointed() // Да!
a.pointless()
(&a).pointless() // Да!
Объясняется это просто. Семантика того, что мы модифицируем или нет, определяется тем, как записан приемник: со звездочкой или без. Что касается остальных параметров, они ведут себя как в функциях.
В Go ограничено приведение конкретных типов к интерфейсам, и это не дает нам делать как случайные модификации оригинала, так и вполне безопасные модификации его копии.
type Red struct{}
func (r Red) String() string {
return "Red"
}
type Green struct {
color string
}
func (g *Green) String() string {
if len(g.color) > 0 {
g.color = "very " + g.color
} else {
g.color = "green"
}
return g.color
}
func Print(s Stringer) {
fmt.Println("my color: ", s)
}
func main() {
var red Red
var green Green
Print(red)
// Print(green) - cannot modify
Print(&green)
Print(&green)
Print(&green)
}
Это убережет нас от модификации типа, потому что у green есть методы, которые модифицируют объект, но мы передаем его по значению. А по значению семантика у нас как раз read only.
p.s. Надеюсь, вам было полезно. Делитесь своими лайфхаками в комментариях! Напоследок, небольшая шпаргалка:
p.p.s. Статья основана на докладе с майского YADRO Go To митапа в Петербурге — найти все материалы с мероприятия вы можете здесь.