Владислав Белогрудов, старший разработчик

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

Комментарии (11)


  1. LaRN
    13.07.2023 07:38
    +1

    Роб Пайк сказал, что простое лучше, чем сложное.

    Вроде это из манифеста python третий пункт, он Guido van Rossum :)


    1. vbelogrudov
      13.07.2023 07:38

      Вы совершенно правы, это говорили многие известные люди :)


  1. dancheg
    13.07.2023 07:38
    +2

    Спасибо за статью!

    KISS важен :) Не совсем к теме статьи, но добавил бы что можно еще поисследовать репозитории людей которые очень давно пишут на go: пример1, пример2, пример3, пример4, пример5.

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


    1. vbelogrudov
      13.07.2023 07:38
      +1

      Вам спасибо за ссылки!


  1. alexanderivanov
    13.07.2023 07:38
    +3

    когда по-другому никак

    Не только map, но и slice и string будут передаваться "по ссылке".

    Но если строки к счастью не получится модифицировать внутри если не "хитрить" например с unsafe указателями, то для слайсов есть нюансы. Например, если внутри функции слайс переаллоцируется, то исходный слайс не меняется, так как на стеке создаётся временный.
    Переаллоцирование слайса гарантированно случится если требуется перераспределение памяти при изменении его capacity. Но так же почему-то новый слайс создастся внутри из-за append'а ДАЖЕ если capacity оригинального слайса достаточное.

    https://go.dev/play/p/6nLdrBKpVPY


    1. SiGGthror
      13.07.2023 07:38
      +2

      Категорически не согласен.

      Для начала стоит сказать, что "переаллоцирование слайса" не происходит. Слайс - это просто структура, которая под собой содержит len, capacity, и указатель на массив, который содержит элементы слайса. Если и происходит переаллокация, то именно массива, а не слайса.

      В функции modifyByAppend на самом деле ничего не переаллоцируется, а пишется значение просто в следующий байт и меняется len на 2, но т.к. значение длины пишется не в тот же самый слайс, по выходу из функции len остается 1. Дальше думаю происходит магия println, который просто не выводит элементы с индексом больше чем len.

      Проверить, что значение в modifyByAppend остается в памяти можно через пакет unsafe.

      https://go.dev/play/p/D9D7vUPNkEK


      1. alexanderivanov
        13.07.2023 07:38

        Согласен, с терминологией порой возникает путаница. Сказал переалоцирование слайса, так как append применяется к слайсу и внешне это именно так и воспринимается, а так да, переалоцирование массива на котором построен(?) слайс.

        Думаю, что и термин "передаваться по ссылке" не совсем точный, точнее было бы сказать, что когда аргумент - это указатель, то внутрь функции попадает копия этого указателя, до тех пор, пока никто эту копию не изменил, и спокойно меняет память указываемую оригинальным и скопированным указателем, путаницы не возникает, так как указывают они на одну память.
        Тут чуть сложнее, не копия указателя, а копия структуры слайса, где хранится указатель на массив на котором построен слайс.
        Но как только внутри функции указатель в копии слайса переписали новым значением (что, вероятно, и делает append вызываемый внутри функции при недостатке capacity), память массива оригинального слайса будет отличаться от того, который был скопирован в функцию, и результат будет неожиданным, если на это не обратить внимание.

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


  1. SiGGthror
    13.07.2023 07:38
    +1

    Если один метод в структуре изменяет объект, а другой — нет, мы все равно ставим приемник со звездочкой, чтобы это было удобнее читать

    Разве это основная мотивация?

    У интерфейсов есть нюанс, который оберегает нас от неожиданной модификации аргумента

    Это не нюанс интерфейсов, а различие между *Bar и Bar. Bar не включает в себя методы *Bar, а вот обратный вариант уже включает.


  1. VladimirFarshatov
    13.07.2023 07:38

    Странно, у меня эскейп анализ выкатывал на стек объекты чуть больше 100 байт на раз.. но, в целом .. не жалею что перешёл с но на С++, хотя приходится много чего изучать повторно. Да, правда теперь забываю ставить круглые скобки у if, for.. Но уже оценил выразительность языка в отличии от го.

    const char const * const myfunc() имеет свой смысл каждый модификатор.. мелочь, а приятно.


    1. Deosis
      13.07.2023 07:38
      +2

      const char const * const myfunc() имеет свой смысл каждый модификатор..

      один const бесполезен


  1. VladimirFarshatov
    13.07.2023 07:38

    В таком ракурсе - да, но могут быть нюансы в С/С++.. ;) В целом, к тому, что язык позволяет точно указывать что хотел разработчик, а не вот это вот "го..". Автор ещё умолчал о проблемах эскейп-анализа, катастрофически влияющих на производительность кода (всё в кучу, в т.ч. и сам стек, никаких инлайн с деферами, и пр.), оригинальный гарбадж-коллектор, запускающийся по умолчанию через .. 1.5секунды от старта (сколько запросов отработает микросервис за это время?) и прочие дефекты, которые вылезают как плата за скорость написания кода.. ГО - это ни разу не про хайлоад в целом. После ковыряния в потрохах Го, Роб Пайк сильно проиграл в моих глазах .. а ведь было время.. ;)

    Это ИМХО, спорить не собираюсь, как это было на собеседовании с автором топика.. хорошо, что не попал.. рад. :)