golang

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

  • среда, 13 марта 2024 г. в 00:00:16
https://habr.com/ru/articles/799331/

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

Эта статья — моя подборка приёмов и техник, которые помогут писать лаконичный и производительный код на 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. Некоторые штуки я регулярно использую, надеюсь они упростят жизнь и вам.

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