А ты хорошо знаешь Go? Держи пару полезностей по оптимизации кода

от автора

Привет, Хабр!

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

Речь пойдёт о:

  • полезностях для конкурентного программирования

  • приёмах в Go в целом, таких как использование iota, работа с ошибками, вывод интерфейса и т.д.

  • методах оптимизации работы со слайсами

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

С чем-то из этого я пересекаюсь особенно часто, некоторые приёмы позаимствованы у коллег, что-то я увидел в интернетах. Уверен, что актуально не только для меня, поэтому и пишу эту статью.

Мой канал с инструментами Go разработчика, с разбором каверзных вопросов с собеседований, примерами с кодом, обучающими уроками и кучей всего полезного, здесь целая папка для всех, кто любит Golang.

Поехали уже!

Полезные штуки для конкурентного программирования

Связка цикла for и select

Позволяет избежать блокировки цикла в одном из состояний, все происходит внутри одной горутины:

func(s *sub) loop() {   // ... определяем изменеяемое состояние ...   for {       // ... задаем каналы для  разных случаев ...       select {           case <-c1: // прочитать из канала без сохранения в переменную            // ... прочитать/записать состояние ...           case с2 <- x: // записать в канал            // ... прочитать/записать состояние ...                   case y := <-c3: // прочитать из канала в переменную            // ... прочитать/записать состояние ...       }   } } 

Как видно, в этом нашем select мы обрабатываем все возможные случаи:

  • если значение может быть прочитано из канала без сохранения в переменную

  • если значение переменной может быть записано в канал

  • если значение может быть прочитано из канала и сохранено в переменную

Поскольку цикл for бесконечный, оператор select будет выбирать один из случаев и выполнять соответствующий код. Затем цикл повторяется, и процесс повторяется снова.

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

Служебный канал, канал для ответа (chan chan error)

Когда мы используем горутины, проверка завершения выполнения по булеву флагу может привести к гонке данных. Состояние гонки можно увидеть при помощи go build -race main.go. Чтобы этого избежать создадим канал, передающий канал.

type sub struct {   closing chan chan error // запрос ответ } // использование func (s *sub) Close() {   errChan = make(chan error)   s.closing <- errChan   return <-errChan } // Обработка сигнала закрытия в loop func (s *sub) loop() {   // ...   var err error // задается когда в произошла ошибка во время выполнения основной работы   for {     select {       case errChan := <-s.closing: // проверяем есть ли сигнал на завершение работы         errChan <- err // вернем ошибку через предоставленный канал, может быть nil или объект error         close(s.updates) // закрываем канал пересылки для данных в основную горутину         return // завершим работу loop()     }   }  // ... } 

Выглядит достаточно странно, канал с каналом ошибок. Эта конструкция позволяет сделать двунаправленный обмен между горутинами. Мы передаем канал через который vs можем вернуть ответ. Метод loop() это как небольшой сервер, и чтобы его остановить мы даем ему запрос на прекращение работы — пишем значение в канал sub.closing, в канал мы передаем канал в который сервер поместит ответ, когда закончит работу. В случае штатной остановки в канале будет nil, иначе канал вернет error.

Преимущества всей этой конструкции очевидны:

  1. Синхронизация: при помощи каналов мы синхронизируем выполнение горутин, обеспечивая правильный порядок выполнения операций и предотвращая состояние гонки.

  2. Безопасность: использование каналов обеспечивает безопасность доступа к данным при параллельном выполнении операций. Каналы гарантируют, что только одна горутина может получить доступ к данным в определенный момент времени.

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

nil-каналы в выражениях select для временной приостановки

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

var pending []Item // заполняется процедурой получения данных, очищается процедурой отправки отчетов о работе // отправляем информацию о завершенной задаче в канал s.updates for {   select {   case s.updates <- pending[0]: // отправляем первый элемент     pending = pending[1:] // когда отправка удаласть, удаляем первый элемент из массива переписывая слайс без него   } } // это будет падать с ошибкой 

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

У каналов есть такая особенность: если каналу присвоить значение nil, то отправка и прием блокируется в этом канале. Мы его деинициализируем вручную.

Эту особенность можно применять для введения временной блокировки.

Пример кода:

a := make(chan string) go func(){ a <- "a" }() a = nil select { case s:=<-a: // тут канал будет заблокирован   fmt.Println("s=", s) } 

Так мы можем временно отключать некоторые варианты исполнения в select.
Итак исправим проблему этим методом:

var pending []Item // заполняется процедурой получения данных, очищается процедурой отправки отчетов о работе // отправляем информацию о завершенной задаче в канал s.updates for {   var first Item   var updates chan Item   if len(pending) > 0 {     first = pending[0]     updatesChan = s.updates // укажем реальный канал, чтобы разблокировать исполнение в select   }    select {   case updatesChan <- first: // отправляем первый элемент, заблокируется, если канал s.updates = nil     pending = pending[1:] // когда отправка удаласть, удаляем первый элемент из массива переписывая слайс без него   } } 

Здесь мы используем бесконечный цикл for, в котором происходит отправка элементов из списка pending в канал updates. Если список pending не пустой, то из него извлекается первый элемент и сохраняется в переменной first, а также устанавливается канал updatesChan равным каналу s.updates.

Затем выполняется выражение select с одним случаем, соответствующим отправке элемента first в канал updatesChan. Если канал s.updates не равен nil, то отправка элемента в канал приведет к разблокировке горутины, которая ждет данные из этого канала. После успешной отправки элемента из списка pending удаляется первый элемент.

Если же канал s.updates равен nil, то отправка элемента в канал приведет к блокировке горутины, которая будет ждать, пока в канал не будет отправлен хотя бы один элемент. Таким образом, горутина приостанавливается до тех пор, пока не будет готов принять данные другой горутины, которая отправляет данные в этот канал.

Как-то так работает простая и эффективная синхронизация горутин в Go; мы используем каналы для передачи данных и select для ожидания событий.

Довольно простые и популярные приёмы Go

Проверка наличия ключа в map

Очевидная вещь, и многие это знают, но я просто обязан об этом упомянуть. Чтобы проверить, есть ли ключ в map, просто записываем результат взятия по ключу в 2 переменных, вторая переменная — error или bool:

_, keyIsInMap := myMap["keyToCheck"] if !keyIsInMap {   fmt.Println("key not in map") } 

Проверка при приведении типов переменной

Иногда нужно преобразовать переменную из одного типа в другой. Проблема в том, что в случае неверного типа код запаникует. Например, следующий код пытается привести переменную data к строковому типу string:

value := data.(string) 

Если преобразование data в тип string не произойдет, код запаникует. Поэтому рассмотрим способ преобразования лучше.

Аналогично проверке наличия ключа в map: при приведении типов получаем логическое значение и проверяем, произошло приведение или нет:

value, ok := data.(string) 

В этом примере ok —  логическое значение, которое сообщает, было ли приведение типов успешным или нет. Таким образом работа с несоответствием типов ведется более изящно, чем при механизме паники.

Указание размера массива при использовании append для оптимизации

Для добавления элементов в массив лучше всего задействовать append. Например:

for _, v := range inputArray {   myArray = append(myArray, v) } 

Однако в случае больших массивов процесс добавления замедлится, потому что append потребуется постоянно увеличивать размер myArray для новых значений, это постоянная реаллокация памяти. Лучше сначала указать длину массива, а затем присвоить каждое значение напрямую:

myArray := make([]int, len(inputArray))  for i, v := range inputArray {   myArray[i] = v  } } 

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

myArray := make([]int, 0, len(inputArray)) for _, v := range inputArray {   myArray = append(myArray, v) } 

Здесь размер массива устанавливается равным 0, а максимальный размер задается равным длине входного массива. Поэтому append не потребуется менять размер на ходу.

Использование append и многоточия для объединения массивов

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

myArray = append(myArray, value1) 

И append позволяет добавлять несколько элементов одновременно:

myArray = append(myArray, value1, value2) 

Но самое крутое  —  это распаковка слайса с помощью ... при передаче его в функцию. Итак, объединяем слайс inputArray с myArray:

myArray = append(myArray, inputArray...) 

При этом происходит распаковка элементов inputArray и мы добавляем их в конец myArray.

Отображение имен и значений параметров при выводе структуры

Всё время теперь этим пользуюсь. Раньше для отображения имен и значений параметров в структуре я выполнял маршалинг в JSON и логировал это. Но есть гораздо более простой способ: при выполнении Printf добавлять + или # в формат, в зависимости от того, что вы хотите вывести.

package main import "fmt"  func main() {     MyStruct := struct{         Value1 string         Value2 int     }{Value1:"first value", Value2:2}          fmt.Printf("%v \n", MyStruct)  // {first value 2}      fmt.Printf("%+v \n", MyStruct) // {Value1:first value Value2:2}     fmt.Printf("%#v \n", MyStruct) // struct { Value1 string; Value2 int }{Value1:"first value", Value2:2} } 

Задействование iota с пользовательскими типами при перечислении

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

type PossibleStates int  const (  State1 PossibleStates = iota  // 0  State2                        // 1  State3                        // 2 ) 

Здесь мы создаём пользовательский тип PossibleStates как алиас над int, после чего каждое перечисление будет иметь тип PossibleState, значение которого присваивается ключевым словом iota.

В итоге State1, State2, State3 будут иметь значения 0, 1, 2 соответственно.

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

Этот прием стал для меня откровением. Допустим, имеется интерфейс, который надо сымитировать:

type DataPersistence interface {  SaveData(string, string) error  GetData(string) (string, error) } 

Это интерфейс для нескольких различных типов этой persistence. Нужно протестировать код, поэтому создадим имитированную структуру DataPersistence для использования в тестах. Но вместо написания сложной имитированной структуры просто создадим структуру с параметрами, которые являются функциями, соответствующими интерфейсным функциям.

Вот как будет выглядеть имитация:

type MockDataPersistence struct {  SaveDataFunc func(string, string) error  GetDataFunc func(string) (string, error) }  // SaveData просто вызывает параметр SaveDataFunc func (mdp MockDataPersistence) SaveData(key, value string) error {  return mdp.SaveDataFunc(key, value) }  // GetData просто вызывает параметр GetDataFunc func (mdp MockDataPersistence) GetData(key string) (string, error) {  return mdp.GetDataFunc(key) } 

Это означает, что при тестировании функции настраиваются, как нам надо, прямо в этом же тесте:

func TestMyStuff(t *testing.T) {  mockPersistor := MockDataPersistence{}  // здесь устанавливаем SaveData, чтобы просто вернуть ошибку  mockPersistor.SaveDataFunc = func(key, value string) error {   return fmt.Errorf("error to check how your code handles an error")  }  // теперь проверяем, как thingToTest (то, что тестируется) разбирается с тем, когда   // SaveData возвращает ошибку  err := thingToTest(mockPersistor)  assert.Nil(t, err) } 

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

Создание собственного интерфейса в случае его отсутствия

Допустим, вы используете другую библиотеку Go, и там есть структура, но интерфейса не сделано  —  создайте его сами. Вот, например, эта структура:

type OtherLibsStruct struct {}  func (ols OtherLibsStruct) DoCoolStuff(input string) error {  return nil } 

Прямо в коде создаем нужный интерфейс:

type InterfaceForOtherLibsStruct interface {  DoCoolStuff(string) error } 

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

Инстанцирование вложенных анонимных структур

А этот прием мне приходилось задействовать несколько раз при использовании сгенерированного кода. Иногда при кодогенерации получается вложенная анонимная структура. Скажем, что-то такое:

type GeneratedStuct struct {   Value1 string `json:"value1"`   Value2 int `json:"value2"`   Value3 *struct {     NestedValue1 string `json:"NestedValue1"`     NestedValue2 string `json:"NestedValue2"`   } `json:"value3,ommitempty"` } 

Допустим, теперь надо создать экземпляр этой структуры для использования. Как это сделать? С Value1 и Value2 всё просто, но как инстанцировать указатель на анонимную структуру Value3?

Первое, что приходит на ум: записать его в JSON, а затем маршализовать в структуру. Но это ужасно и как-то по-дилетантски. Оказывается, нужно использовать другую анонимную структуру при ее инстанцировании:

myGeneratedStruct := GeneratedStuct{   Value3: &struct {    NestedValue1 string `json:"NestedValue1"`    NestedValue2 string `json:"NestedValue2"`   }{    NestedValue1: "foo",    NestedValue2: "bar",   },  } 

В целом очевидно, но имейте в виду, что эта анонимная структура должна точно соответствовать, вплоть до тегов JSON.

К примеру, из-за несоответствия типов не удастся скомпилировать вот это, хотя на первый взгляд всё то же самое:

myGeneratedStruct := GeneratedStuct{   Value3: &struct {    NestedValue1 string `json:"nestedValue1"`    NestedValue2 string `json:"nestedValue2"`   }{    NestedValue1: "foo",    NestedValue2: "bar",   },  } 

Приёмы работы со слайсами

Фильтрация без аллокации памяти

Для избежания реаллокации мы используем факт, что срез слайса a[:0] указывает на тот же массив и имеет ту же ёмкость, что и оригинальный слайс a.

b := a[:0] for _, x := range a {     if f(x) {               // f() - некая фильтрующая функция         b = append(b, x)     } } 

b := a[:0] — создаём срез b нулевой длины, но с той же ёмкостью, что и слайс a.

Для элементов, которые должны быть удалены сборщиком мусора, можно использовать что-то в духе:

for i := len(b); i < len(a); i++ {     a[i] = nil } 

Разворачивание слайса

Чтобы заполнить слайс его же элементами, но в обратном порядке, можно сделать так:

for i := len(a)/2-1; i >= 0; i-- {     opp := len(a)-1-i     a[i], a[opp] = a[opp], a[i] } 

То же самое, только с двумя индексами:

for left, right := 0, len(a)-1; left < right; left, right = left+1, right-1 {     a[left], a[right] = a[right], a[left] } 

Перемешиваем слайс

Перемешать элементы слайса можно так, тут мы используем алгоритм Фишера-Йетса и math/rand:

for i := len(a) - 1; i > 0; i-- {     j := rand.Intn(i + 1)     a[i], a[j] = a[j], a[i] } 

А вообще, начиная с Go 1.10, мы можем использовать готовую функцию math/rand.Shuffle:

rand.Shuffle(len(a),               func(i, j int) { a[i], a[j] = a[j], a[i] }) 

Создание батчей с минимальным выделением ресурсов

Это полезно, если вы хотите выполнять пакетную обработку больших срезов.

actions := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} batchSize := 3 batches := make([][]int, 0, (len(actions) + batchSize - 1) / batchSize)  for batchSize < len(actions) {     actions, batches = actions[batchSize:], append(batches, actions[0:batchSize:batchSize]) } batches = append(batches, actions)  // [[0 1 2] [3 4 5] [6 7 8] [9]] 

Чуть подробнее, что у нас тут происходит:

  1. Объявляем слайс actions с целыми числами от 0 до 9.

  2. Задаём размер батча (размер подслайса) batchSize, равный 3.

  3. Создаём пустой слайс batches для хранения батчей. Его ёмкость устанавливаем в (len(actions) + batchSize - 1) / batchSize, чтобы убедиться, что в нём будет достаточно места для всех батчей.

  4. Запускаем цикл for, который работает, пока размер батча batchSize меньше длины слайса actions. Внутри цикла:

    • Обновляем слайс actions, отбрасывая первые batchSize элементов с помощью операции actions[batchSize:].

    • Обновляем слайс batches путём добавления нового батча, созданного из первых batchSize элементов слайса actions с помощью операции append(batches, actions[0:batchSize:batchSize]).

  5. После окончания цикла, когда длина actions меньше или равна batchSize, добавляем оставшиеся элементы в batches с помощью операции append(batches, actions).

Выкидываем дубли (дедупликация)

import "sort"  in := []int{3,2,1,4,3,2,1,4,1} // любой элемент можно отсортировать sort.Ints(in) j := 0 for i := 1; i < len(in); i++ { if in[j] == in[i] { continue } j++ // сохраняем исходные данные // in[i], in[j] = in[j], in[i] // устанавливаем только то, что требуется in[j] = in[i] } result := in[:j+1] fmt.Println(result) // [1 2 3 4] 

Чуть подробнее:

  1. Сортируем срез in с помощью sort.Ints(in) в возрастающем порядке.

  2. Создаём j для отслеживания позиции, где поместим следующий уникальный элемент.

  3. Внутри цикла проверяем, равен ли текущий элемент in[j]. Если равен, то пропускаем текущую итерацию цикла с помощью continue, не изменяя j.

  4. А если текущий элемент не равен in[j], увеличиваем j на 1 и копируем текущий элемент в позицию in[j]. Это означает, что in[j] теперь содержит уникальное значение, и j указывает на следующую позицию, где следует поместить следующий уникальный элемент.

Как-то так можно насобирать в слайс result все уникальные значения из in.

Перемещение элемента на передний план или вставка, если элемент отсутствует

// moveToFront перемещает needle в начало среза func moveToFront(needle string, haystack []string) []string {     if len(haystack) != 0 && haystack[0] == needle {         return haystack     }     prev := needle     for i, elem := range haystack {         switch {         case i == 0:             haystack[0] = needle             prev = elem         case elem == needle:             haystack[i] = prev             return haystack         default:             haystack[i] = prev             prev = elem         }     }     return append(haystack, prev) }  haystack := []string{"a", "b", "c", "d", "e"} // [a b c d e] haystack = moveToFront("c", haystack)         // [c a b d e] haystack = moveToFront("f", haystack)         // [f c a b d e] 

В целом, код довольно прозрачный:

  1. Если первый элемент слайса равен needle, функция возвращает слайс без изменений.

  2. Иначе, функция проходит по слайсу помощью цикла for и перемещает элементы, пока не найдет needle.

  3. Когда needle найден, функция помещает предыдущий элемент в текущую позицию и прерывает цикл, возвращая измененный слайс.

  4. Если needle не найден, функция добавляет prev (последний элемент слайса) в конец и возвращает измененный слайс.

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

Скользящее окно

Нарезаем наш слайс на пересекающиеся куски нужного размера.

func slidingWindow(size int, input []int) [][]int {     // возвращает входной срез как первый элемент     if len(input) <= size {         return [][]int{input}     }      // выделяем срез точного размера, который нам нужен     r := make([][]int, 0, len(input)-size+1)      for i, j := 0, size; j <= len(input); i, j = i+1, j+1 {         r = append(r, input[i:j])     }      return r }  a:=[]int{1,2,3,4,5} aa := slidingWindow(3, a) fmt.Println(aa) // [[1 2 3] [2 3 4] [3 4 5]] 

Вообще, вся эта история с нарезанием слайса активно используется в обработке сигналов, анализе временных рядов и особенно в ML.

The end

Что ж, это мы рассмотрели полезные фишки и приёмы в Go. Некоторые штуки я регулярно использую, надеюсь они упростят жизнь и вам.

Пишите, с чем доводилось сталкиваться на практике — будет интересно почитать)


ссылка на оригинал статьи https://habr.com/ru/articles/799331/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *