Привет, Хабр!
Эта статья — моя подборка приёмов и техник, которые помогут писать лаконичный и производительный код на 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.
Преимущества всей этой конструкции очевидны:
-
Синхронизация: при помощи каналов мы синхронизируем выполнение горутин, обеспечивая правильный порядок выполнения операций и предотвращая состояние гонки.
-
Безопасность: использование каналов обеспечивает безопасность доступа к данным при параллельном выполнении операций. Каналы гарантируют, что только одна горутина может получить доступ к данным в определенный момент времени.
-
Управление ошибками: каналы позволяют нам передавать информацию об ошибках между горутинами, что позволяет эффективно обрабатывать ошибки.

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]]
Чуть подробнее, что у нас тут происходит:
-
Объявляем слайс
actionsс целыми числами от 0 до 9. -
Задаём размер батча (размер подслайса)
batchSize, равный 3. -
Создаём пустой слайс
batchesдля хранения батчей. Его ёмкость устанавливаем в(len(actions) + batchSize - 1) / batchSize, чтобы убедиться, что в нём будет достаточно места для всех батчей. -
Запускаем цикл
for, который работает, пока размер батчаbatchSizeменьше длины слайсаactions. Внутри цикла:-
Обновляем слайс
actions, отбрасывая первыеbatchSizeэлементов с помощью операцииactions[batchSize:]. -
Обновляем слайс
batchesпутём добавления нового батча, созданного из первыхbatchSizeэлементов слайсаactionsс помощью операцииappend(batches, actions[0:batchSize:batchSize]).
-
-
После окончания цикла, когда длина
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]
Чуть подробнее:
-
Сортируем срез
inс помощьюsort.Ints(in)в возрастающем порядке. -
Создаём
jдля отслеживания позиции, где поместим следующий уникальный элемент. -
Внутри цикла проверяем, равен ли текущий элемент
in[j]. Если равен, то пропускаем текущую итерацию цикла с помощьюcontinue, не изменяяj. -
А если текущий элемент не равен
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]
В целом, код довольно прозрачный:
-
Если первый элемент слайса равен
needle, функция возвращает слайс без изменений. -
Иначе, функция проходит по слайсу помощью цикла
forи перемещает элементы, пока не найдетneedle. -
Когда
needleнайден, функция помещает предыдущий элемент в текущую позицию и прерывает цикл, возвращая измененный слайс. -
Если
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/
Добавить комментарий