golang

Простые правила, которые помогают мне писать на Go без побочных эффектов

  • пятница, 14 июля 2023 г. в 00:00:22
https://habr.com/ru/companies/yadro/articles/747308/
Владислав Белогрудов, старший разработчик

Успел поработать с роботами, телекомом, поисковиками. В 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-байтовому указателю. А вот после стековая копия всё росла, и передавать по адресу стало явно выгоднее по времени. 

OS Linux, amd64, CPU 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz, pkg: gitflic.ru/vlad-belogrudov/meetup/cmd/books
OS Linux, amd64, CPU 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz, pkg: gitflic.ru/vlad-belogrudov/meetup/cmd/books

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

Что лучше возвращать: сам объект или указатель?

Мы можем скопировать объект из стека наверх, либо воспользоваться синтаксисом new и make для возврата указателя. Я снова взял книжки разного размера и провел эксперимент. 

Для книжек небольшого объема явно выиграл объект. Потому что значение было сразу в стеке, а не в куче. Аллокации в куче обходятся очень дорого, потому что нам надо найти или освободить память. 

OS Linux, amd64, CPU 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz, pkg: gitflic.com/vlad-belogrudov/meetup/cmd/books
OS Linux, amd64, CPU 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz, pkg: gitflic.com/vlad-belogrudov/meetup/cmd/books

Дальше оказалось, что вплоть до 10 мегабайт мы можем передавать объекты по стеку быстрее, чем делать через new и отдавать адрес. 

OS Linux, amd64, CPU 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz, pkg: gitflic.com/vlad-belogrudov/meetup/cmd/books
OS Linux, amd64, CPU 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz, pkg: gitflic.com/vlad-belogrudov/meetup/cmd/books

Практически все можно передавать по стековому копированию: ведь придумать объект, который будет весить больше 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; }

Тем самым мы показываем, что объект изменяем в принципе, даже если какие-то методы его не изменяют.

Интерфейсы сделают код более гибким, независимым и SOLID-ным

Поэтому интерфейс стоит определять именно как контракт между вашей функцией, методом и аргументом, который она принимает. Нам без разницы, какой аргумент, лишь бы у него был нужный метод.

Интерфейсы предпочитают указатели для производительности

Интерфейсы в 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 митапа в Петербурге — найти все материалы с мероприятия вы можете здесь.