Некоторые подводные грабли в языке Go

13 ноября 2019

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

Примечание: Все приведенные примеры можно запустить на play.golang.org.

Когда nil не равен nil

Рассмотрим следующую программу:

package main

import "fmt"

type myType struct{}

func checkIsNil(arg interface{}) {
    if arg != nil {
        panic("Not nil!")
    }
}

func main() {
    var nilPointer *myType = nil
    checkIsNil(nilPointer)
    fmt.Println("Done!")
}

По принципу наименьшего удивления все проверки будут пройдены, и программа выведет «Done!». Но на самом деле она завершится с паникой «Not nil!». Почему же так происходит?

Оказывается, что interface в языке Go представляет собой пару полей. Первое поле хранит информацию о типе, а второе — конкретное значение, реализующее интерфейс. Так вот, интерфейс равен nil только когда первое поле равно nil, а второе не указано. В данном случае это не так, потому что первое поле содержит *myType, а второе — nil. Соответственно, проверка не проходит.

Если в Go вы хотите получить nil-интерфейс, то должны явно возвращать или передавать nil-интерфейс. В частности, checkIsNil(nil) отработает ожидаемым образом.

На nil можно вызывать методы

Вот вам еще немного веселья с nil:

package main

import "fmt"

type MyStruct struct {}

func (ms *MyStruct) SayHello(name string) {
    fmt.Printf("Hello, %s!\n", name)
}

func main() {
    var ptr *MyStruct = nil
    ptr.SayHello("Alex")
}

Заметьте, никаких интерфейсов в программе нет, поэтому предыдущий пункт не при делах. Интуитивно, программа должна упасть с nil pointer dereference, поскольку мы вызываем метод на nil. Однако программа выводит «Hello, Alex!», как ни в чем не бывало.

Просто «методы» в Go — вовсе никакие не методы, а всего лишь синтаксический сахар над обычными процедурами. То есть, здесь происходит вызов процедуры SayHello с двумя аргументами, первый из которых nil. Никаких проблем! Конечно, если не пытаться разыменовывать ms.

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

Тонкости работы defer

Рассмотрим такой пример:

package main

import "fmt"

func main() {
    {
        defer fmt.Println("Defer called")
        fmt.Println("About to leave the scope")
    }
    fmt.Println("About to leave main()")
}

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

About to leave the scope
About to leave main()
Defer called

Все потому что defer — это не «своеобразный вызов деструктора», как о нем хотелось бы думать. Defer ничего не знает ни о каких скоупах, и указанный после него вызов всегда происходит при выходе из функции. Если defer’ов в коде было несколько, соответствующий вызовы осуществляются в обратном порядке.

Частный случай номер раз:

package main

import "fmt"

func main() {
    for i := 0; i < 3; i++ {
        defer fmt.Printf("Defer called for i = %v\n", i)
        fmt.Printf("For loop, i = %v\n", i)
    }
    fmt.Println("About to leave main()")
}

Вывод программы:

For loop, i = 0
For loop, i = 1
For loop, i = 2
About to leave main()
Defer called for i = 2
Defer called for i = 1
Defer called for i = 0

Частный случай номер два:

package main

import (
    "fmt"
    "os"
)

func main() {
    defer fmt.Println("Defer called")
    fmt.Println("About to call os.Exit()")
    os.Exit(0)
}

В этом случае defer вообще ничего не сделает, потому что мы никогда не выйдем из main().

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

Создание горутин в цикле for

Что еще является источником самых разнообразных ошибок, это цикл for.

Например:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    values := []string{"alpha", "beta", "gamma"}
    for _, v := range values {
        wg.Add(1)
        go func() {
            fmt.Println(v)
            wg.Done()
        }()
    }

    wg.Wait()
}

Выглядит вполне законно. И если бы мы писали на нормальном функциональном языке, все было бы хорошо. Однако мы пишем на Go, и потому программа выводит:

gamma
gamma
gamma

Что же пошло не так? Оказывается, что v — это «магическая» переменная, которая как бы хранит ссылку на элементы слайса values. Когда созданные нами горутины получают управление, значение переменной уже изменилось, и потому все идет вкривь и вкось.

Для решения проблемы код можно переписать так:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    values := []string{"alpha", "beta", "gamma"}
    for _, v := range values {
        wg.Add(1)
        go func(arg string) {
            fmt.Println(arg)
            wg.Done()
        }(v)
    }

    wg.Wait()
}

… или так:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    values := []string{"alpha", "beta", "gamma"}
    for _, v := range values {
        v := v
        wg.Add(1)
        go func() {
            fmt.Println(v)
            wg.Done()
        }()
    }

    wg.Wait()
}

Для нормальных людей v := v, конечно, выглядит дико, но по ходу в Go это идиоматичный код.

Запись в закрытый канал паникует

И напоследок совсем простенький пример:

package main

import "fmt"

func main() {
    ch := make(chan bool, 1)
    close(ch)
    ch <- true
    fmt.Println("Done!")
}

Программа завершится с паникой send on closed channel. Запись в закрытый канал — это всегда паника, и, соответственно, падение всего приложения. Выглядит относительно безобидно и даже логично. Но на практике, когда код разрабатывался разными людьми на протяжении нескольких лет, сами понимаете, бывает непросто отследить жизненный цикл всех каналов.

Заключение

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

Например, если вы не очень опытны в написании многопоточных программ, то почти наверняка соберете немало гонок. Особенно если вместо каналов вы решите использовать мьютексы и атомики. Но это специфика написания многопоточного кода в целом, а не какая-то особенность Go. Также есть разные способы словить утечки памяти. А еще бывают, к примеру, тонкости разработки распределенных систем. Ничего специфичного для Go нет ни в первом, ни во втором. Наконец, банальный nil pointer dereference никто не отменял.

Из чего-то специфичного для Go на ум приходит разве что какие-нибудь recover или goplugins. Насколько я осведомлен, их следует использовать примерно никогда и ни для чего. Даже если вам кажется, что у вас очень особенный случай, и решается он именно этими инструментами, это не так. Любой, кто пытался с этим работать, скажет вам то же самое.

Дополнение: Другая интересная особенность языка заключается в том, что его стандартная библиотека не содержит процедуры для копирования файлов. Те же реализации, что вы найдете в интернете, содержат весьма неочевидные ошибки. Нормальную реализацию можно найти в этой статье после фразы «A more robust version with more helpful errors would be».

Дополнение: Некоторые тонкости управления зависимостями в Go

Метки: .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.