программы

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

1764
2
30 августа 2023
Изображение создано с помощью нейросети
программы
1764
2
30 августа 2023
Простые правила, которые помогают писать на Go без побочных эффектов

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

Изображение создано с помощью нейросети

Как правильнее передавать аргументы и возвращать значения

Есть два способа получить результат работы функции. Можно сделать это через входные параметры, указатели. При этом мы можем менять передаваемые аргументы:

А можно пойти прямым путем и возвратить значение через return:

Функции, которые иногда меняют аргументы внезапно для программиста, — это не очень здорово. Подавая на вход неизменяемые аргументы и не используя в них глобальные переменные, мы получаем «чистые» функции. Это избавляет нас от неприятных сюрпризов. На «чистые» функции легко писать тесты, плюс, теперь мы можем легко и достаточно безопасно параллелить функции. А программу будет проще анализировать в потоке данных. Одни плюсы.

Возникает простое правило. Если мы не хотим модифицировать наш аргумент, то передаем его по значению. Если модифицируем, используем указатель.

Как запретить функции модификацию аргументов

Во многих языках для этого есть const, но в Go нет неизменяемых объектов, поэтому здесь const — это просто псевдоним какого-то литерала. И мы не можем с его помощью сказать компилятору: «Пожалуйста, не трогай то, что я сейчас напишу».

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

Но надо помнить, что есть нюансы. Сложные структуры данных могут состоять как из простых типовых структур, так и из указателей. А всё, что мы передаем через указатель, можно легко поменять. Плюс, большинство сложных типов, например, те же slice и map, будут мутабельны, потому что внутри содержат указатели.

Когда нужно передавать через указатель

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

Например, такая структура данных, как map, это по факту указатель на структуру. То есть как map не передавай…

Другой популярный сценарий — это работа с JSON. Например, мы берем строчку и хотим распаковать её в структуру:

Если мы не передадим модифицируемый аргумент в Unmarshal, функция не сможет понять, что ей нужно делать на выходе: выдать TeamScore, вывести OK и 42. Здесь и пригодится указатель.

  • Когда у нас больше 100 байт данных

Я провёл эксперимент, чтобы понять, насколько большие данные нужно передать в функцию для перехода от копирования значения в передачу по адресу.

Я взял книжку, состоящую из array-байтов, чтобы вся память принадлежала ей. Читал её, передавая и по значению, и через звёздочку. И постепенно увеличивал размер.

Чтобы результат был честным, я покопался в директивах компилятора: исходно он оптимизирует маленькие функции, поэтому я запретил ему делать инлайнинг, то есть выдирать тело маленькой функции и подставлять его в вызов. Вот что получилось: вплоть до 100 байт копирование книжки по стеку практически было равно её передаче по 8-байтовому указателю. А вот после стековая копия всё росла, и передавать по адресу стало явно выгоднее по времени.

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

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

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

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

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

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

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

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

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

Побег из стека в кучу (и из кучи в стек)

Хорошее и плохое про стек и кучу

Почему в примере с возвращением объекта всё менялось на отметке в 10 мегабайт? Компилятор занимается так называемым анализом побегов (Escape Analysis). Поэтому он может переносить какие-то наши переменные из стека в кучу и наоборот. Вот здесь определены значения пределов, при которых он может держать что-то в стеке.

Например, когда мы объявляем переменную в стеке и возвращаем её адрес, компилятор сразу разместит Car в куче:

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

А если мы создадим объект до 64 килобайт через функцию new () и решим не возвращать его адрес, компилятор перенесет его в стек. Чтобы проверить это, я создавал книжки, начиная с 8-байтовой. И пока я не достиг 64 килобайт, у меня было 0 аллокаций в куче и все было очень быстро.

Как только я достигал 64 килобайт или больше, появлялись аллокации и все замедлялось.

Методы ведут себя как функции

  • Если мы возьмем приёмник (первый аргумент) по указателю, то сможем менять объект.
  • Если там стоит значение, то метод имеет неизменяемый объект.
  • Приёмники методов ведут себя как параметры функции, ведь по сути они и есть параметры, посыпанные синтаксическим сахаром. Большой приёмник по значению — это много стекового копирования. Лучше использовать приёмник указатель.

Как сделать методы читаемыми для коллег

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

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

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

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

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

Интерфейсы в Go — это структура из двух указателей:

Первый — это указатель на таблицу методов типа данных, на которые сейчас смотрит интерфейс. Второй — это копия данных, которая помогает нам сохранять иммутабельность в случаях, когда она нужна. Но если мы поместим сюда указатель на исходные данные, интерфейс будет приравнен к настоящему объекту, и мы можем его модифицировать.

С точки зрения производительности, у нас есть функция, в которой мы говорим, что аргумент — это интерфейс. И когда мы передаем аргументы в эту функцию, происходит копирование из объекта в этот интерфейс. Рассмотрим пример:

Если в каком-то объекте у нас приёмник со звёздочкой, мы приравниваем его к интерфейсу уже с амперсандом, и можем вызывать модифицирующие методы:

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

Получается, немодифицируемые интерфейсы лучше не использовать часто.

Функции очень строго следят за своими аргументами

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

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

Методы преобразуют любые приёмники, которые мы им дадим

Все варианты ниже сработают:

Объясняется это просто. Семантика того, что мы модифицируем или нет, определяется тем, как записан приемник: со звёздочкой или без. Что касается остальных параметров, они ведут себя как в функциях.

У интерфейсов есть нюанс

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

Это убережет нас от модификации типа, потому что у green есть методы, которые модифицируют объект, но мы передаем его по значению. А по значению семантика у нас как раз read only.

p. s. Надеюсь, вам было полезно. Делитесь своими лайфхаками в комментариях! Напоследок, небольшая шпаргалка:

Все случаи аргументации в Golang
Все случаи аргументации

Статья основана на докладе с майского YADRO Go To митапа в Петербурге — найти все материалы с мероприятия вы можете здесь.

Наверх
2 комментария
  • > Большой приёмник по значению — это много стекового копирования.

    Насколько приёмник должен быть большим?

    • Теодор, привет!
      Для приемника метода действуют такие же ограничения, как для аргументов функций. То есть до ~100 байт можно передавать по значению, дальше время передачи-копирования будет расти. 100 байт — это размер самой структуры, что не включает в себя то, что на самом деле аллоцируется в «куче» или куда указывают указатели в этой структуре (слайс = 24 байта, содерживое не считается — находится вне структуры слайса).