4 подводных камня на Go, на которые часто натыкаются

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

1. Ссылаться на переменную цикла  —  не самая хорошая идея

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

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

package main
import "fmt"
type User struct {
ID int
Name string
}
func main() {
users := []User{{1, "Andy"}, {2, "John"}, {3, "Jane"}}
 userIDs := getUserIDs(users)
for _, id := range userIDs {
fmt.Println(*id)
}
}
func getUserIDs(users []User) []*int {
ids := make([]*int, 0, len(users))
for _, user := range users {
ids = append(ids, &user.ID)
}
return ids
}

Этот код выводит:

3
3
3

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

Простое решение  —  придать циклу следующий вид:

func getUserIDs(users []User) []*int {
ids := make([]*int, 0, len(users))
for _, user := range users {
id := user.ID
ids = append(ids, &id)
}
return ids
}

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

2. Осторожнее с затененными переменными!

Затененные переменные нередко приводят к ошибкам и возникают при повторном использовании имени переменной в более узкой области.

Когда для объявления переменной в Go задействуется :=, она объявляется внутри области, ограниченной {}. Но допустим, то же имя переменной используется в более узкой области, например в операторе if.

В следующем примере кода на верхнем уровне функции для importantVariable установлено значение по умолчанию. Затем в коде выполняются проверки и обновленная переменная оказывается в операторе if. Но выводимое в конце значение не совсем то, что ожидалось. Сможете найти причину?

package main
import (
"fmt"
)
func main() {
importantVariableValue := getImportantVariableValue()
fmt.Println(importantVariableValue)
}
func getImportantVariableValue() string {
importantVariable := "Default Value"
if functionThatChecksShouldWeUseANoneDefaultValue() {
importantVariable := "Non-Default Value"
fmt.Printf("Important Variable updated to: %s\n", importantVariable)
}
return importantVariable
}
func functionThatChecksShouldWeUseANoneDefaultValue() bool {
return true
}

Вот что выводит код:

Important Variable updated to: Non-Default Value
Default Value

А причина в повторном объявлении importantVariable в операторе if в этой строке:

importantVariable := "Non-Default Value"

Это совершенно новая переменная, которая отбрасывается по завершении оператора if.

Это упрощенный пример, поэтому проблему легко обнаружить и устранить. В более сложном коде это сделать труднее.

Также из-за затененных переменных ошибки часто теряются, так как на Go повсюду используется имя переменной err. Это приводит на ложный путь при отладке, превращая ее в мучение.

Зато некоторые средства контроля качества кода на Go автоматически находят затененные переменные.

3. Получатели типа в методах получают копию типа

Метод на Go  —  это функция, связанная с определенным типом. Например:

type MyStruct struct {
Name string
}
func (ms MyStruct) PrintNameMethod() {
fmt.Println(ms.Name)
}

В этом коде функция PrintNameMethod связана с типом MyStruct: вызывается только как часть его экземпляра. В начале метода она получает переменную типа MyStruct с именем ms, позволяя обращаться к значениям экземпляра MyStruct из метода.

А что, если обновить данные MyStruct, как в следующем примере?

package main
import "fmt"
type MyStruct struct {
Name string
}
func (ms MyStruct) PrintNameMethod() {
fmt.Println(ms.Name)
}
func (ms MyStruct) UpdateNameMethod(newName string) {
ms.Name = newName
}
func main() {
mS := MyStruct{Name: "Joe"}
mS.UpdateNameMethod("Jane")
mS.PrintNameMethod()
}

Вот что выведет этот код:

Joe

Дело в том, что функция UpdateNameMethod получила копию MyStruct, которая затем была обновлена. Но исходный MyStruct остался неизменным.

Надо обновить получатель, чтобы он получал указатель на структуру:

func (ms *MyStruct) UpdateName(newName string) {
ms.Name = newName
}

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

Это всем хорошо известно, но очень легко упускается из виду, и тогда отладка превращается в поиск иголки в стоге сена.

4. Вызов методов в пустых структурах

А это источник мучений, сбивающий с толку при выполнении отладки.

В следующем примере кода пытаемся создать DB Struct (структуру БД), но что-то идет не так, появляются ошибки и в итоге остаемся с пустым указателем на структуру. Пример упрощенный, но и такое случается. Где в этом сценарии ожидать паники от кода?

package main
import "fmt"
type DBStruct struct {
DBConnectionName string
}
func (ms *DBStruct) PrintDBConnectionName() {
fmt.Println(ms.DBConnectionName)
}
func main() {
s, _ := getDBStruct()
s.PrintDBConnectionName()
}
func getDBStruct() (*DBStruct, error) {
return nil, fmt.Errorf("oh noes, couldn't create the DB connection")
}

Я ожидал, что он запаникует в этой строке:

s.PrintDBConnectionName()

Здесь пропустили ошибку и значение s пустое. Но оказалось, что код не будет паниковать до этой строчки:

fmt.Println(ms.DBConnectionName)

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

Лучшее решение  —  проявить бдительность и обязательно проверить наличие нулевых указателей.

Заключение

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

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Andrew Hayes: 4 pitfalls in Go I still occasionally fall into

Предыдущая статьяВеб-скрейпинг с нуля на Python: библиотека Beautiful Soup