А ты хорошо знаешь Go? Держи пару полезностей по оптимизации кода
Привет, Хабр!
Эта статья — моя подборка приёмов и техник, которые помогут писать лаконичный и производительный код на Go без лишних костылей и велосипедов.
Речь пойдёт о:
полезностях для конкурентного программирования
приёмах в Go в целом, таких как использование
iota
, работа с ошибками, вывод интерфейса и т.д.методах оптимизации работы со слайсами
Обсудим, как избежать ненужной аллокации памяти, как быть с состоянием гонки, поговорим про компактность и лаконичность кода и ещё про массу полезных штук.
С чем-то из этого я пересекаюсь особенно часто, некоторые приёмы позаимствованы у коллег, что-то я увидел в интернетах. Уверен, что актуально не только для меня, поэтому и пишу эту статью.
Мой канал с инструментами Go разработчика, с разбором каверзных вопросов с собеседований, примерами с кодом, обучающими уроками и кучей всего полезного, здесь целая папка для всех, кто любит GO .
Поехали уже!
Полезные штуки для конкурентного программирования
Связка цикла 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
или nil
:
_, 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. Некоторые штуки я регулярно использую, надеюсь они упростят жизнь и вам.
Пишите, с чем доводилось сталкиваться на практике — будет интересно почитать)