50 відтінків Go по-українськи. Аналізуємо помилки

Автор або джерело: Ярослав Характерник

Першоджерело

Go tech,

Повна версія

50 відтінків Go по-українськи. Аналізуємо помилки

Підписуйтеся наTelegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привіт, мене звати Ярослав, маю три роки досвіду з Go та пару років досвіду написання статей на тему Go. У своїх статтях наводив приклади помилок, ще й друзі та колеги пропонували приклади помилок від себе, зараз прикладів настільки багато, що настав час винести їх в окрему статтю. Статтю про помилки вже писали раніше, вона відома гоферам під назвою 50 Shades of Go і її назву я запозичив для написання цієї статті «50 відтінків Go по-українськи».

У цій статті розглянемо приклади з оригінальної статті, а також нові, і щоб було цікавіше, будемо перевіряти чи знаходить ці приклади помилок статичний аналізатор коду. Стаття буде цікава гоферам: як спеціалістам, так і новачкам.

Ілюстрації до статті зробилаЛюдмила Тіторенко

Не бійся того, що видиме компілятору

Оригінальна стаття 50 відтінків Go була написана ще в травні 2015 року, або навіть раніше, і на початку містить примітивні приклади помилок, про які каже компілятор. Але чим далі читаєш, то кориснішими є приклади помилок. Для того, щоб знайти помилки, які пропустив компілятор, використовують статичні аналізатори коду та code review.

Статичний аналізатор коду (або linter) — це інструмент, який може форматувати код, знаходити і виправляти помилки, а також знаходити дублікати коду. Прикладом аналізатору є Go critic.

Статичних аналізаторів коду на Go багато і вибрати найкращий складно, тому є запускатори, які запускають різні аналізатори коду паралельно. Найпопулярніший запускатор golangci-lint:

golangci-lint is a fast Go linters runner. It runs linters in parallel, uses caching, supports yaml config, has integrations with all major IDE and has dozens of linters included.

Оригінальна стаття 50 відтінків Go була написана до появи golangci-lint та fasthttp, але приклади помилок з fasthttp будуть в цій статті.

Початок роботи з golangci-lint

golangci-lint можна використовувати без налаштування, а простота використання така ж, як і у go fmt.

Встановити golangci-lint можна через brew , curl , wget або go get. Я вибрав go get , а ви можете вибрати будь-який варіант з документації.

go get github.com/golangci/golangci-lint/cmd/[email protected]


golangci-lint version


golangci-lint has version v1.36.0

Як запустити перевірку коду:

golangci-lint run
golangci-lint run ./...
golangci-lint run ./components/... ./models/... ./controllers/...

Процес розбору прикладів помилок

Кожна помилка унікальна, має свій рівень складності та загрози, а тому формат розробки буде відрізнятись: від простого «ось помилка, а ось так треба» до детального з причиною виникнення та посиланнями на Stack Overflow.

Щоб після детальних розборів дати вам можливість відпочити, буду чергувати детальні розбори з короткими.

Приклад пропущеної варіативної розпаковки

Розглянемо простий приклад варіативної розпаковки:

package main

import "fmt"

func main() {
    fmt.Println("Sum(1, 2, 3) = ", Sum(1, 2, 3))

    {
        var nums = []int{1, 2, 3, 4}

        fmt.Println("Sum(1, 2, 3, 4) = ", Sum(nums...))
    }

    {
        fmt.Println("Sum([]int{1, 2, 3, 4, 5}...) = ", Sum([]int{1, 2, 3, 4, 5}...))
    }

    // compilation error
    // cannot use []int literal (type []int) as type int in argument to Sum
    // {
    //  fmt.Println("Sum([]int{1, 2, 3, 4, 5}) = ", Sum([]int{1, 2, 3, 4, 5}))
    // }
}

func Sum(nums ...int) int {
    var total = 0

    for _, num := range nums {
        total += num
    }

    return total
}


go run main.go


Sum(1, 2, 3) =  6
Sum(1, 2, 3, 4) =  10
Sum([]int{1, 2, 3, 4, 5}...) =  15

Якщо розкоментуємо код, де помилково пропустили розпаковку Sum([]int{1, 2, 3, 4, 5}) , то компілятор повідомить про помилку:

cannot use []int literal (type []int) as type int in argument to Sum

А ось справжня помилка, про яку мовчить компілятор, виникає, коли функція очікує (values ...interface{}) , бо навіть пропущена варіативна розпаковка у випадку interface{} буде сприйматись за коректний код:

package main

import (
    "log"
)

const (
    i = "\x1b[92m[INFO]\x1b[0m"
    e = "\x1b[91m[ERROR]\x1b[0m"
)

func Infof(format string, a ...interface{}) {
    log.Printf(i+" "+format, a)
}

// nolint:deadcode,unused
func Errorf(format string, a ...interface{}) {
    log.Printf(e+" "+format, a)
}

func main() {
    Infof("Fetch %d rows from %s by %d ms", 64, "projects", 5)
}


go run ./examples/01-01-fmt-print-args/main.go


2021/02/10 12:00:00 [INFO] Fetch [64 %!d(string=projects) 5] rows from %!s(MISSING) by %!d(MISSING) ms

А я очікував іншу відповідь:

2021/02/10 12:00:00 [INFO] Fetch 64 rows from projects by 5 ms

А що скаже аналізатор коду?

golangci-lint run ./examples/01-01-fmt-print-args/main.go


(аналізатор коду мовчить, тому уявіть, що тут мем розгубленого Траволти)

Аналізатор коду мовчить, тому просто виправимо помилку:

func Infof(format string, a ...interface{}) {
    // invisible error:
    // log.Printf(i+" "+format, a)
    // fix:
    log.Printf(i+" "+format, a...)
}


go run ./examples/01-01-fmt-print-args/main.go


2021/02/10 12:10:00 [INFO] Fetch 64 rows from projects by 5 ms

На прикладі fmt.Println помилка пропущеної розпаковки виглядає безкривдним одруком. Але якщо розглядати на прикладі запитів до БД, то вже серйозно.

Отже, у нас є функція для отримання з БД інформації про проекти FetchProjectsByCompany :

import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql"
)

type Project struct {
    ID        uint32
    CompanyID uint32
    Direction string
    Title     string
}

func FetchProjectsByCompany(connection *sql.DB, companyID, limit, offset uint32) ([]Project, error) {
    const (
        // language=MySQL
        query = `SELECT id,
       company_id,
       direction,
       title
FROM projects
WHERE company_id = ?
LIMIT ?, ?;`
    )

    rows, err := connection.Query(query, companyID, offset, limit)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var result = make([]Project, 0, limit)

    for rows.Next() {
        var project Project

        err = rows.Scan(
            &project.ID,
            &project.CompanyID,
            &project.Direction,
            &project.Title,
        )

        if err != nil {
            return nil, err
        }

        result = append(result, project)
    }

    return result, nil
}

І в проекті потрібна ще функція FetchProjectsByDirection , яка схожа на першу FetchProjectsByCompany і тому винесли спільний код:

func FetchProjectsByCompany(connection *sql.DB, companyID, limit, offset uint32) ([]Project, error) {
    const (
        // language=MySQL
        query = `SELECT id,
       company_id,
       direction,
       title
FROM projects
WHERE company_id = ?
LIMIT ?, ?;`
    )

    return fetchProjects(connection, query, limit, companyID, offset, limit)
}

func FetchProjectsByDirection(connection *sql.DB, direction string, limit, offset uint32) ([]Project, error) {
    const (
        // language=MySQL
        query = `SELECT id,
       company_id,
       direction,
       title
FROM projects
WHERE direction = ?
LIMIT ?, ?;`
    )

    return fetchProjects(connection, query, limit, direction, offset, limit)
}

func fetchProjects(connection *sql.DB, query string, limit uint32, args ...interface{}) ([]Project, error) {
    rows, err := connection.Query(query, args...)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var result = make([]Project, 0, limit)

    for rows.Next() {
        var project Project

        err = rows.Scan(
            &project.ID,
            &project.CompanyID,
            &project.Direction,
            &project.Title,
        )

        if err != nil {
            return nil, err
        }

        result = append(result, project)
    }

    return result, nil
}

Перевірив, працює як раніше. Але якщо пропустимо розпакувати варіативність connection.Query(query, args) , то отримаємо помилку під час виконання програми (що вже серйозно):

go build -o 01-02-mysql-args ./examples/01-02-mysql-args/main.go


./01-02-mysql-args


converting argument $1 type: unsupported type []interface {}, a slice of interface

І знову спробуємо golangci-lint , можливо для SQL запитів помітить помилку і підкаже:

golangci-lint run ./examples/01-02-mysql-args/main.go


(знову розгублений Траволта)

Раз golangci-lint знову мовчить, то можливо, пошук помилок з варіативністю треба додатково налаштувати, але пошук показав що лінтери, які перевіряють варіативність відсутні.

Поки ми бачимо, що помилка пропущеної варіативності є, але golangci-lint її пропускає, то можливо є ситуації, коли ця поведінка є правильною.

Тепер розглянемо ситуацію, коли пропущена варіативність є очікуваною логікою, на прикладі Redis клієнта github.com/go-redis/redis.

Якщо ви знайомі з Redis, то користувались командою SADD, обгортка для якої в github.com/go-redis/redis виглядає так:

func (c cmdable) SAdd(ctx context.Context, key string, members ...interface{}) *IntCmd {
    // ...
}

Ми розглянемо три приклади використання SAdd :

package main

import (
    "context"
    "fmt"
    "github.com/go-redis/redis/v8"
    "reflect"
)

func main() {
    var redisClient = redis.NewClient(&redis.Options{
        Addr:     "localhost:6379",
        Password: "", // no password set
        DB:       0,  // use default DB
    })
    defer redisClient.Close()

    {
        var err = redisClient.Del(context.Background(), "l:online").Err()
        if err != nil {
            panic(err)
        }
    }

    // Example 1: unpack variadic arguments
    {
        var members = []interface{}{
            "1001",
            "1002",
            "1003",
        }

        var err = redisClient.
            SAdd(context.Background(), "l:online", members...).
            Err()
        if err != nil {
            panic(err)
        }
    }

    // Example 2: unpack variadic arguments missing
    {
        var members = []interface{}{
            "2004",
            "2005",
            "2006",
        }

        var err = redisClient.
            SAdd(context.Background(), "l:online", members).
            Err()
        if err != nil {
            panic(err)
        }
    }

    // Example 3: unpack variadic arguments missing
    {
        var members = []string{
            "3007",
            "3008",
        }

        var err = redisClient.
            SAdd(context.Background(), "l:online", members).
            Err()
        if err != nil {
            panic(err)
        }
    }

    // Example 4: unpack variadic arguments missing
    {
        var members = map[string]interface{}{
            "4009": "4010",
        }

        var err = redisClient.
            SAdd(context.Background(), "l:online", members).
            Err()
        if err != nil {
            panic(err)
        }
    }

    {
        var values, err = redisClient.
            SMembers(context.Background(), "l:online").
            Result()
        if err != nil {
            panic(err)
        }

        var expect = []string{
            "1001",
            "1002",
            "1003",
            "2004",
            "2005",
            "2006",
            "3007",
            "3008",
            "4009",
            "4010",
        }

        if !reflect.DeepEqual(expect, values) {
            fmt.Printf("expect %+v, got %+v\n", expect, values)
        }
    }

    fmt.Println("success")
}


go run ./examples/01-03-redis-args/main.go


success

У першому прикладі Example 1: unpack variadic arguments була використана розпакова, яку очікувала функція, а у другому та третьому прикладі ми пропустили розпаковку, але дані успішно додались. Це тому для бібліотеки github.com/go-redis/redis така поведінка очікувана:

func appendArg(dst []interface{}, arg interface{}) []interface{} {
    switch arg := arg.(type) {
    case []string:
        for _, s := range arg {
            dst = append(dst, s)
        }
        return dst
    case []interface{}:
        dst = append(dst, arg...)
        return dst
    case map[string]interface{}:
        for k, v := range arg {
            dst = append(dst, k, v)
        }
        return dst
    default:
        return append(dst, arg)
    }
}

Приклад дуже простий, його розбір зайняв багато коду та тексту, але стало зрозуміло, що статичний аналізатор коду може пропускати такі помилки, а з іншої сторони пропущена розпакова аргументу може бути очікуваною поведінкою.

Використання значення до перевірки на наявність помилки

Щоб відновити вашу довіру до статичного аналізатору коду, розглянемо помилку Closing HTTP Response Body:

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    resp, err := http.Get("https://api.ipify.org?format=json")
    defer resp.Body.Close() //not ok
    if err != nil {
        fmt.Println(err)
        return
    }

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Println(string(body))
}


go run ./examples/02-01-defer-before-err/main.go


{"ip":"127.0.0.1"}


golangci-lint run ./examples/02-01-defer-before-err/...


examples/02-01-defer-before-err/main.go:11:8: httpresponse: using resp before checking for errors (govet)
    defer resp.Body.Close() //not ok
          ^

Аналізатор коду знайшов помилку (Chookity pok!).

Виправили і аналізатор перестав сваритись:

package main

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

func main() {
    resp, err := http.Get("https://api.ipify.org?format=json")
    if err != nil {
        fmt.Println(err)
        return
    }
    defer resp.Body.Close()

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Println(string(body))
}

Довіру до golangci-lint відновлено.

Захоплення змінної циклу

Кожен гофер хоч раз, але зустрічав таку помилку:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var values = []string{"one", "two", "three"}
    var wg = new(sync.WaitGroup)

    wg.Add(3)
    for _, value := range values {
        go func() {
            fmt.Println(value)

            wg.Done()
        }()
    }

    wg.Wait()
}


go run ./examples/03-01-closure-for-iteration-var-err/main.go


three
three
three

Ця помилка виникає через оптимізацію, в якій ітераційна змінна перевикористовується на кожній ітерації циклу і тому замикання (closure) посилаються на одну змінну.
Ця ж помилка в інших варіантах проходу циклу:

    wg.Add(3)
    for i := range values {
        go func() {
            fmt.Println(values[i])

            wg.Done()
        }()
    }

    wg.Wait()


go run ./examples/03-02-closure-for-iteration-index-var-err/main.go


three
three
three

Якщо запустити з опцією -race , то компілятор повідомить про помилку:

go run -race ./examples/03-01-closure-for-iteration-var-err/main.go


==================
WARNING: DATA RACE
Read at 0x00c0000a21e0 by goroutine 7:
  main.main.func1()
      ~/go/src/gitlab.com/go-yp/fifty-shades-of-go/examples/03-01-closure-for-iteration-var-err/main.go:15 +0x3c

Previous write at 0x00c0000a21e0 by main goroutine:
  main.main()
      ~/go/src/gitlab.com/go-yp/fifty-shades-of-go/examples/03-01-closure-for-iteration-var-err/main.go:13 +0x122

Goroutine 7 (running) created at:
  main.main()
      ~/go/src/gitlab.com/go-yp/fifty-shades-of-go/examples/03-01-closure-for-iteration-var-err/main.go:14 +0x168
==================
two
three
three
Found 1 data race(s)
exit status 66

Так само повідомить про помилку статичний аналізатор коду:

golangci-lint run ./examples/03-01-closure-for-iteration-var-err/main.go


examples/03-01-closure-for-iteration-var-err/main.go:15:16: loopclosure: loop variable value captured by func literal (govet)
            fmt.Println(value)

Є пара варіантів виправлення:

package main

import (
    "fmt"
    "sync"
)

func main() {
    var values = []string{"one", "two", "three"}
    var wg = new(sync.WaitGroup)

    // Fix 1.0
    {
        wg.Add(3)
        for _, value := range values {
            valueCopy := value

            go func() {
                fmt.Println("valueCopy := value", valueCopy)

                wg.Done()
            }()
        }
    }

    // Fix 1.1
    {
        wg.Add(3)
        for _, value := range values {
            value := value

            go func() {
                fmt.Println("value := value    ", value)

                wg.Done()
            }()
        }
    }

    // Fix 2
    {
        wg.Add(3)
        for _, value := range values {
            go func(value string) {
                fmt.Println("func(value string)", value)

                wg.Done()
            }(value)
        }
    }

    wg.Wait()
}


go run -race  ./examples/03-03-closure-for-iteration-var/main.go


valueCopy := value one
valueCopy := value two
valueCopy := value three
value := value     one
value := value     two
value := value     three
func(value string) one
func(value string) two
func(value string) three



golangci-lint run ./examples/03-03-closure-for-iteration-var/main.go


(помилка виправлена, тому golangci-lint мовчить)

І go run -race і golangci-lint знайшли помилку.

Приклад помилки взятий з оригінальної статті Iteration Variables and Closures in «for» Statements.

Захоплення змінної-вказівника циклу

Це різновид попередньої помилки і щоб зрозміти її краще то напишемо тест:

type Repository struct {
    ID   uint32
    Name string
    URL  string
}

func ToRepositoryMap(repositories []Repository) map[uint32]*Repository {
    var length = len(repositories)

    if length == 0 {
        return nil
    }

    var result = make(map[uint32]*Repository, length)

    for _, repository := range repositories {
        result[repository.ID] = &repository
    }

    return result
}


import (
    "github.com/stretchr/testify/require"
    "testing"
)

func TestToRepositoryMap(t *testing.T) {
    var repositories = []Repository{
        {
            ID:   1,
            Name: "name-1",
            URL:  "url-1",
        },
        {
            ID:   2,
            Name: "name-2",
            URL:  "url-2",
        },
        {
            ID:   3,
            Name: "name-3",
            URL:  "url-3",
        },
    }

    var expect = map[uint32]*Repository{
        1: {
            ID:   1,
            Name: "name-1",
            URL:  "url-1",
        },
        2: {
            ID:   2,
            Name: "name-2",
            URL:  "url-2",
        },
        3: {
            ID:   3,
            Name: "name-3",
            URL:  "url-3",
        },
    }

    var actual = ToRepositoryMap(repositories)

    require.Equal(t, expect, actual)
}


go test ./examples/04-01-iteration-pointer-var/... -v


            Diff:
            --- Expected
            +++ Actual
            @@ -2,10 +2,10 @@
              (uint32) 1: (*main.Repository)({
            -  ID: (uint32) 1,
            -  Name: (string) (len=6) "name-1",
            -  URL: (string) (len=5) "url-1"
            +  ID: (uint32) 3,
            +  Name: (string) (len=6) "name-3",
            +  URL: (string) (len=5) "url-3"
              }),
              (uint32) 2: (*main.Repository)({
            -  ID: (uint32) 2,
            -  Name: (string) (len=6) "name-2",
            -  URL: (string) (len=5) "url-2"
            +  ID: (uint32) 3,
            +  Name: (string) (len=6) "name-3",
            +  URL: (string) (len=5) "url-3"
              }),
--- FAIL: TestToRepositoryMap (0.00s)

Щоб було зрозуміліше, то ми очікували expected , а ToRepositoryMap повернув actual :

var expect = map[uint32]*Repository{
    1: {
        ID:   1,
        Name: "name-1",
        URL:  "url-1",
    },
    2: {
        ID:   2,
        Name: "name-2",
        URL:  "url-2",
    },
    3: {
        ID:   3,
        Name: "name-3",
        URL:  "url-3",
    },
}

var actual = map[uint32]*Repository{
    1: {
        ID:   3,
        Name: "name-3",
        URL:  "url-3",
    },
    2: {
        ID:   3,
        Name: "name-3",
        URL:  "url-3",
    },
    3: {
        ID:   3,
        Name: "name-3",
        URL:  "url-3",
    },
}

require.Equal(t, expect, actual)

Чому виникає така помилка, писав раніше:

ця помилка виникає через оптимізацію, в якій ітераційна змінна перевикористовується на кожній ітерації циклу

І звісно цікаво, чи помітить таку помилку golangci-lint

golangci-lint run ./examples/04-01-iteration-pointer-var/...

golangci-lint

Виправлення помилки (як і в попередньому прикладі):

for _, repository := range repositories {
    repository := repository

    result[repository.ID] = &repository
}



go test ./examples/04-01-iteration-pointer-var/... -v


=== RUN   TestToRepositoryMap
--- PASS: TestToRepositoryMap (0.00s)
PASS
ok      gitlab.com/go-yp/fifty-shades-of-go/examples/04-01-iteration-pointer-var    0.018s

Отож, коли ви в циклі бачите value := value , то краще залиште без змін. Інколи value := value є зайвим і його додав розробник, щоб перестрахуватись, у такому випадку теж залиште, як було (або пишіть тест і прибирайте).

Приклад явного приведення вкладених типів

Go легко приводить тип до інтерфейсу, але є ложка дьогтю з вкладними типами. Спочатку розглянемо успішний приклад:

package main

import "fmt"

type Company struct {
    name string
}

func NewCompany(name string) *Company {
    return &Company{name: name}
}

func (c *Company) Name() string {
    return c.name
}

type NameGetter interface {
    Name() string
}

func main() {
    var company = NewCompany("evrius")

    // Example 1
    {
        var getter NameGetter = company

        fmt.Printf("var getter NameGetter = company %s\n", getter.Name())
    }

    // Example 2
    {
        fmt.Printf("(NameGetter)(company) %s\n", (NameGetter)(company).Name())
    }
}


go run ./examples/05-01-strict-type-convertion/main.go


var getter NameGetter = company Evrius
(NameGetter)(company) Evrius

А тепер спробуємо ускладнити приклад і обернути у функцію:

func main() {
    var company = NewCompany("Evrius")

    {
        var getCompanyFunc = func() *Company {
            return company
        }

        // cannot do this
        var nameGetterFunc func() NameGetter = getCompanyFunc

        fmt.Printf("NameGetterFunc %s\n", nameGetterFunc().Name())
    }
}


go run ./examples/05-01-strict-type-convertion/main.go


# command-line-arguments
examples/05-01-strict-type-convertion/main.go:30:7: cannot use getCompanyFunc (type func() *Company) as type func() NameGetter in assignment

Виходить, що тип *Company до типу NameGetter привести можна , а тип func() *Company до типу func() NameGetter буде помилкою. Такі приведення треба робити явно:

func main() {
    var company = NewCompany("Evrius")

    {
        var getCompanyFunc = func() *Company {
            return company
        }

        var nameGetterFunc func() NameGetter = func() NameGetter {
            return getCompanyFunc()
        }

        fmt.Printf("NameGetterFunc %s\n", nameGetterFunc().Name())
    }
}

У реальних проектах зустрічав такі приведення типів.

А окрім обгортки func , є ще slice , map та навіть interface і усюди треба писати додатковий код для приведення вкладених типів.

Помилка приведення типів без перевірки

У Go відсутні дженерики, а тому всі універсальні бібліотеки приймають interface{} , це різноманітні кеші та драйвери для NoSQL.

Ми вже розглядали в попередньому прикладі приведення типів, яке перевіряється при компіляції:

var getter NameGetter = company

Але також є приведення типів, яке перевіряється в момент виконання програми, можливе лише для інтерфейсів:

package main

import "fmt"

func main() {
    dump("Привіт, світе!")
}

func dump(value interface{}) {
    var s = value.(string)

    fmt.Println("dump( " + s + " )")
}


go run ./examples/06-01-type-convertion/main.go


dump( Привіт, світе! )

А якщо передамо замість «Привіт, світе!» значення 42 :

package main

import "fmt"

func main() {
    dump(42)
}

func dump(value interface{}) {
    var s = value.(string)

    fmt.Println("dump( " + s + " )")
}


go build -o 06-02-type-convertion-interface-to-string-err ./examples/06-02-type-convertion-interface-to-string-err/main.go


./06-02-type-convertion-interface-to-string-err


panic: interface conversion: interface {} is int, not string

Бачимо, що програма скомпілювалась успішно, а в момент виконання отримали паніку panic: interface conversion: interface {} is int, not string.

golangci-lint run ./examples/06-02-type-convertion-interface-to-string-err/main.go

golangci-lint також пропустив цю помилку конвертації типів.

І правильний робочий варіант:

package main

import (
    "fmt"
    "math"
)

func main() {
    dump("Привіт, світе!")
    dump(42)
    dump(math.Pi)
}

func dump(value interface{}) {
    {
        var s, ok = value.(string)
        if ok {
            fmt.Println("dump( " + s + " )")

            return
        }
    }

    {
        var i, ok = value.(int)
        if ok == true {
            fmt.Println("dump(", i, ")")

            return
        }
    }

    fmt.Println("dump(", value, ")")
}


go run ./examples/06-03-type-convertion-interface-to-string-and-int/main.go


dump( Привіт, світе! )
dump( 42 )
dump( 3.141592653589793 )

Якщо ви сумніваєтесь в типі, то краще додатково перевірте: var i, ok = value.(int)

Помилки роботи зі switch

Перед тим як перейдемо до помилки, розберемо switch. Switch в Go відрізняється від switch в C++, PHP та JavaScript.

Для кращого розуміння, чому з’являються помилки в switch, розглянемо switch в JavaScript, перетворимо в switch в Go, а потім ще спростимо.

Приклад switch для JavaScript:

for (let i = 0; i < 5; i++) {
    console.log(i);

    switch (i) {
        case 1:
        case 2:
            console.log("один або два");

            break;
        case 3:
            console.log("три");

            break;
        default:
            console.log("будь-яке число");

            break;
    }
}


node ./examples/07-01-switch-js/main.js


0

будь-яке число
1
один або два
2
один або два
3
три
4
будь-яке число

На відміну від JavaScript, в Go блок case за замовчуванням завершується break. Це тому, що в C-подібних мовах програмування пропущений break є джерелом помилок.

Для провалення в case додали нове ключове слово fallthrough. Приклад на Go:

package main

import (
    "fmt"
)

func main() {
    for i := 0; i < 5; i++ {
        fmt.Println(i)

        switch i {
        case 1:
            fallthrough
        case 2:
            fmt.Println("один або два")

            break
        case 3:
            fmt.Println("три")

            break
        default:
            fmt.Println("будь-яке число")

            break
        }
    }
}


go run ./examples/07-02-switch-with-break/main.go


0
будь-яке число
1
один або два
2
один або два
3
три
4
будь-яке число

Як і писав break за замовчуванням, тому можемо скоротити до наступного виду:

package main

import (
    "fmt"
)

func main() {
    for i := 0; i < 5; i++ {
        fmt.Println(i)

        switch i {
        case 1:
            fallthrough
        case 2:
            fmt.Println("один або два")
        case 3:
            fmt.Println("три")
        default:
            fmt.Println("будь-яке число")
        }
    }
}

Друга особливість Go, що є багатоумовні case і тому можна об’єднати:

package main

import (
    "fmt"
)

func main() {
    for i := 0; i < 5; i++ {
        fmt.Println(i)

        switch i {
        case 1, 2:
            fmt.Println("один або два")
        case 3:
            fmt.Println("три")
        default:
            fmt.Println("будь-яке число")
        }
    }
}

Перша можлива помилка:

package main

import (
    "fmt"
)

func main() {
    for i := 0; i < 5; i++ {
        fmt.Println(i)

        switch i {
        case 1:
        case 2:
            fmt.Println("один або два")
        case 3:
            fmt.Println("три")
        default:
            fmt.Println("будь-яке число")
        }
    }
}


go run ./examples/07-04-switch-missing-merge-err/main.go


0
будь-яке число
1
2
один або два
3
три
4

case 1: має break за замовчуванням і фактично працює як case 1: break

golangci-lint run ./examples/07-04-switch-missing-merge-err/main.go

Така поведінка може бути очікуваною або ж помилкою, а тому golangci-lint мовчить.

Друга помилка — це те, що перестаючи використовувати break за призначенням, очікуєш, що break зупинить виконання циклу:

package main

import (
    "fmt"
)

func main() {
    for i := 0; i < 5; i++ {
        fmt.Println(i, "start")

        switch i {
        case 1:
            continue
        case 2:
            fmt.Println("один або два")

            break
        case 3:
            fmt.Println("три")
        default:
            fmt.Println("будь-яке число")
        }

        fmt.Println(i, "finish")
    }
}


go run ./examples/07-05-switch-continue/main.go


0 start
будь-яке число
0 finish
1 start
2 start
один або два
2 finish
3 start
три
3 finish
4 start
будь-яке число
4 finish

Як показав вивід в терміналі: continue працює як і очікували, а break зупиняє виконання лише switch, навіть якщо чомусь очікували, що break зупинить виконання циклу

golangci-lint run ./examples/07-05-switch-continue/main.go


examples/07-05-switch-continue/main.go:17:4: S1023: redundant break statement (gosimple)
            break
            ^

Якщо ми хочемо, щоб break завершив виконання циклу, то треба робити це явно:

package main

import (
    "fmt"
)

func main() {
loop:
    for i := 0; i < 5; i++ {
        fmt.Println(i, "start")

        switch i {
        case 1:
            continue loop
        case 2:
            fmt.Println("один або два")

            break loop
        case 3:
            fmt.Println("три")
        default:
            fmt.Println("будь-яке число")
        }

        fmt.Println(i, "finish")
    }
}


go run ./examples/07-06-switch-continue-explicitly/main.go


0 start
будь-яке число
0 finish
1 start
2 start
один або два

Тепер працює як і очікували. В оригінальній статті ця помилка описується також Breaking Out of «for switch» and «for select» Code Blocks.

Помилка з sync.Once

Для того щоб зрозуміти як працює sync.Once , достатньо глянути код стандартної бібліотеки (та розуміти як працює atomic та mutex):

package sync

import (
    "sync/atomic"
)

// Once is an object that will perform exactly one action.
type Once struct {
    done uint32
    m    Mutex
}

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 {
        // Outlined slow-path to allow inlining of the fast-path.
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}

Отже, sync.Once має виконати один раз дію, а всі горутини будуть чекати завершення виконання цієї дії f func() , після виконання sync.Once зберігає внутрішній стан o.done що «дія виконалась».

Але в коді одного проекту зустрів таку помилку:

func importCategories() {
    // bad code
    (&sync.Once{}).Do(func() {
        categoryMap = make(map[string]Category)
    })

    var sourceCategories, err = fetchCategories()
    if err != nil {
        // some action

        return
    }

    var replace = make(map[string]Category, len(sourceCategories))
    for _, category := range sourceCategories {
        replace[category.Alias] = category
    }

    mu.Lock()
    categoryMap = replace
    mu.Unlock()
}

func findCategory(alias string) (Category, bool) {
    mu.RLock()
    var category, exists = categoryMap[alias]
    mu.RUnlock()

    return category, exists
}

А тепер подивимось, чи знаходить цю помилку go run -race та golangli- lint на схожому прикладі:

package main

import (
    "fmt"
    "sync"
)

var (
    cache map[string]int
    mu    = new(sync.Mutex)
)

func increment(key string) {
    // bad code
    (&sync.Once{}).Do(func() {
        cache = make(map[string]int)
    })

    mu.Lock()
    cache[key] += 1
    mu.Unlock()
}

func get(key string) int {
    mu.Lock()
    var result = cache[key]
    mu.Unlock()

    return result
}

func main() {
    var wg = new(sync.WaitGroup)

    const (
        oneCount   = 100
        twoCount   = 1000
        threeCount = 10000
    )

    {
        wg.Add(1)
        go func() {
            for i := 0; i < oneCount; i++ {
                increment("one")
            }

            wg.Done()
        }()
    }

    {
        wg.Add(1)
        go func() {
            for i := 0; i < twoCount; i++ {
                increment("two")
            }

            wg.Done()
        }()
    }

    {
        wg.Add(1)
        go func() {
            for i := 0; i < threeCount; i++ {
                increment("three")
            }

            wg.Done()
        }()
    }

    wg.Wait()

    fmt.Println("one expect", oneCount, "got", get("one"))
    fmt.Println("two expect", twoCount, "got", get("two"))
    fmt.Println("three expect", threeCount, "got", get("three"))
}


go run ./examples/08-01-sync-once-do-err/main.go


one expect 100 got 0
two expect 1000 got 1
three expect 10000 got 0


go run -race ./examples/08-01-sync-once-do-err/main.go


==================
WARNING: DATA RACE
Write at 0x0000006268e0 by goroutine 9:
  main.increment.func1()
      ~/go/src/gitlab.com/go-yp/fifty-shades-of-go/examples/08-01-sync-once-do-err/main.go:15 +0x48

one expect 100 got 0
two expect 1000 got 0
three expect 10000 got 1
Found 1 data race(s)


golangci-lint run ./examples/08-01-sync-once-do-err/main.go


(golangci-lint run мовчить)

Як бачимо, go run -race повідомив про помилку, а golangci-lint run помилку пропустив.

Виправлення очікуване:

var (
    cache map[string]int
    mu    = new(sync.Mutex)
    once  = new(sync.Once)
)

func increment(key string) {
    once.Do(func() {
        cache = make(map[string]int)
    })

    mu.Lock()
    cache[key] += 1
    mu.Unlock()
}


go run -race ./examples/08-02-sync-once-do/main.go


one expect 100 got 100
two expect 1000 got 1000
three expect 10000 got 10000

Приклад goroutine leak

Протікання пам’яті (memory leak) — дуже відома помилка, а протікання горутин можна вважати її різновидом, бо горутини займають пам’ять і накопичуються.

Протікання горутин — це помилка, коли розробник очікував завершення горутини, а горутина заблокована і висить у пам’яті.

Давайте розглянемо штучний приклад протікання горутин:

package main

import (
    "fmt"
    "runtime"
    "time"
)

func main() {
    fmt.Println("Number of runnable goroutines on start: ", runtime.NumGoroutine(), "memory usage", memory(), "KB")

    const limit = 1 << 20

    var startTime = time.Now()

    for i := 0; i < limit; i++ {
        // create new eternity goroutine in each iteration
        go func() {
            // infinity loop
            for {
                runtime.Gosched()
            }
        }()

        fmt.Println("Number of runnable goroutines: ", runtime.NumGoroutine(), "memory usage", memory(), "KB")

        time.Sleep(time.Millisecond)
    }

    var duration = time.Since(startTime)

    fmt.Println("Complete by", duration.Seconds(), "seconds")
}

func memory() uint64 {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)

    return m.TotalAlloc / 1024
}


go run ./examples/09-01-goroutine-leak-for/main.go


Number of runnable goroutines on start:  1 memory usage 158 KB
Number of runnable goroutines:  2 memory usage 160 KB
Number of runnable goroutines:  3 memory usage 161 KB
Number of runnable goroutines:  4 memory usage 164 KB
Number of runnable goroutines:  5 memory usage 168 KB
...
Number of runnable goroutines:  1048573 memory usage 453941 KB
Number of runnable goroutines:  1048574 memory usage 453941 KB
Number of runnable goroutines:  1048575 memory usage 453942 KB
Number of runnable goroutines:  1048576 memory usage 453942 KB
Number of runnable goroutines:  1048577 memory usage 453942 KB
Complete by 1213.937323576 seconds

Як бачимо, на кожній ітерації створюється вічна горутина з for:

golangci-lint run ./examples/09-01-goroutine-leak-for/main.go


(golangci-lint мовчить)

У реальних проектах протікання горутин відбувається переважно з причини заблокованих каналів (на запис або читання).

Ось ще один очевидний приклад протікання горутин:

package main

import (
    "fmt"
    "math/rand"
    "runtime"
    "time"
)

type Result struct {
}

func main() {
    for {
        first("Gopher")

        fmt.Println("Number of runnable goroutines: ", runtime.NumGoroutine())

        time.Sleep(time.Millisecond)
    }
}

// bad code
func first(query string) Result {
    var result = make(chan Result)

    // goroutine A
    go func() {
        time.Sleep(time.Duration(rand.Intn(1)) * time.Second)

        result <- Result{}
    }()

    // goroutine B
    go func() {
        time.Sleep(time.Duration(rand.Intn(2)) * time.Second)

        result <- Result{}
    }()

    return <-result
}


go run ./examples/09-03-goroutine-leak-chan/main.go


Number of runnable goroutines:  2
...
Number of runnable goroutines:  20421
Number of runnable goroutines:  20422
Number of runnable goroutines:  20423

Таке протікання відбувається через те, що в небуферизований канал пишемо два рази а читаємо один, і відповідно одна з горутин яка пише й блокується назавжди, що й приводить до протікання горутин.

Протікання горутин в реальному проекті має складнішу логіку, ніж у наведенному вище прикладі.

golangci-lint run ./examples/09-03-goroutine-leak-chan/main.go


(golangci-lint мовчить)

Щоб попередній приклад перестав протікати, потрібно використовувати буферизований канал:

package main

import (
    "fmt"
    "math/rand"
    "runtime"
    "time"
)

type Result struct {
}

type Search func(query string) Result

func main() {
    var (
        search = func(query string) Result {
            return Result{}
        }

        fSearch = func(query string) Result {
            time.Sleep(time.Duration(rand.Intn(2)) * time.Second)

            return Result{}
        }

        sSearch = func(query string) Result {
            time.Sleep(time.Duration(rand.Intn(3)) * time.Second)

            return Result{}
        }
    )

    for {
        first("Gopher", search, fSearch, sSearch)

        fmt.Println("Number of runnable goroutines: ", runtime.NumGoroutine())

        time.Sleep(time.Millisecond)
    }
}

func first(query string, searches ...Search) Result {
    var result = make(chan Result, len(searches))

    for _, search := range searches {
        go func(search Search) {
            result <- search(query)
        }(search)
    }

    return <-result
}


go run ./examples/09-04-goroutine-buffered-chan/main.go


...
Number of runnable goroutines:  1342
Number of runnable goroutines:  1341
Number of runnable goroutines:  1340
Number of runnable goroutines:  1341
Number of runnable goroutines:  1342
...
Number of runnable goroutines:  1275
Number of runnable goroutines:  1277
Number of runnable goroutines:  1278
...

Як бачимо, горутини перестали протікати, але якась частина горутин в процесі роботи, і тому можливе помилкове враження, що протікання є.

golangci-lint пропускає горутинові протікання, а отже потрібен інструмент, який може про це сповістити. Звісно, можна усе покрити тестами і використовувати Goroutine leak detector github.com/uber-go/goleak, але це довго.

Простіший варіант — це використовувати метрики, які будуть показувати число запущений горутин та інші показники. Стандартне рішення для метрик — це Grafana та Prometheus, а для збереження метрик рекомендую VictoriaMetrics.

Опис помилки в оригінальній статті Blocked Goroutines and Resource Leaks.

Помилка копіювання sync.WaitGroup

Почнемо з причин виникнення помилки. У Go гарною практикою (best practices) вважається написання структур, які можна використовувати без ініціалізації:

package main

import (
    "bytes"
    "fmt"
    "sync"
)

func main() {
    {
        var mu sync.Mutex

        mu.Lock()
        mu.Unlock()
    }

    {
        var wg sync.WaitGroup

        wg.Add(1)
        wg.Done()

        wg.Wait()
    }

    {
        var buffer bytes.Buffer
        buffer.Write([]byte("Привіт, світе!"))

        {
            var value = buffer.Bytes()

            fmt.Printf("В буфері %d байт '%s'\n", len(value), value)
        }

        buffer.Reset()

        {
            var value = buffer.Bytes()

            fmt.Printf("В буфері %d байт '%s'\n", len(value), value)
        }

        fmt.Printf("Ємність буфера %d байт\n", buffer.Cap())
    }
}


go run main.go


В буфері 25 байт 'Привіт, світе!'
В буфері 0 байт ''
Ємність буфера 64 байт

Методи цих структур очікують вказівник:

func (m *Mutex) Lock() {
    // ...
}

func (m *Mutex) Unlock() {
    // ...
}

func (wg *WaitGroup) Add(delta int) {
    // ...
}

func (wg *WaitGroup) Done() {
    wg.Add(-1)
}

func (wg *WaitGroup) Wait() {
    // ...
}

У контексті однієї функції Go самостійно додає вказівник до попереднього прикладу:

func main() {
    {
        var mu sync.Mutex

        (&mu).Lock()
        (&mu).Unlock()
    }

    {
        var wg sync.WaitGroup

        (&wg).Add(1)
        (&wg).Done()

        (&wg).Wait()
    }

    {
        var buffer bytes.Buffer
        (&buffer).Write([]byte("Привіт, світе!"))

        {
            var value = (&buffer).Bytes()

            fmt.Printf("В буфері %d байт '%s'\n", len(value), value)
        }

        (&buffer).Reset()

        {
            var value = (&buffer).Bytes()

            fmt.Printf("В буфері %d байт '%s'\n", len(value), value)
        }

        fmt.Printf("Ємність буфера %d байт\n", (&buffer).Cap())
    }
}


go run main.go


В буфері 25 байт 'Привіт, світе!'
В буфері 0 байт ''
Ємність буфера 64 байт

І цей вказівник, який Go самостійно додає, можна пропустити при ручному або автоматичному рефакторінгу.

Розглянемо помилку втрати вказівника на штучному прикладі з рефакторингом. Ось штучний приклад розпаралелення на підзадачі та очікування завершення всіх підзадач:

package main

import (
    "fmt"
    "sync"
    "time"
)

type Repository struct {
    ID        int
    StarCount int
}

func main() {
    var startTime = time.Now()

    var repositories = fetchRepositoriesByIDs([]int{1296269, 265554657, 264964189})

    for _, repository := range repositories {
        fmt.Printf("repository %10d has %3d stars\n", repository.ID, repository.StarCount)
    }

    fmt.Printf("complete %d repositories by %.3f seconds\n", len(repositories), time.Since(startTime).Seconds())
}

func fetchRepositoriesByIDs(ids []int) []Repository {
    var (
        result = make([]Repository, len(ids))
        wg     sync.WaitGroup
    )

    for i, id := range ids {
        wg.Add(1)

        go func(i, id int) {
            result[i] = Repository{
                ID:        id,
                StarCount: fetchStarCountByRepositoryID(id),
            }

            wg.Done()
        }(i, id)
    }

    wg.Wait()

    return result
}

func fetchStarCountByRepositoryID(id int) int {
    // emulate slow http request by sleep
    time.Sleep(100 * time.Millisecond)

    // emulate response
    var stars = id % 1024

    return stars
}


go run ./examples/10-01-waitgroup-example/main.go


repository    1296269 has 909 stars
repository  265554657 has 737 stars
repository  264964189 has  93 stars
complete 3 repositories by 0.100 seconds

Тепер зроблю рефакторинг, використовуючи IDE. Виділяю мишкою блок коду:

wg.Add(1)

go func(i, id int) {
    result[i] = Repository{
        ID:        id,
        StarCount: fetchStarCountByRepositoryID(id),
    }

    wg.Done()
}(i, id)

І натискаю комбінацію клавіш Ctrl+Alt+M (в IDE Goland) та отримую:

func fetchRepositoriesByIDs(ids []int) []Repository {
    var (
        result = make([]Repository, len(ids))
        wg     sync.WaitGroup
    )

    for i, id := range ids {
        fetchRepositoryByID(wg, result, i, id)
    }

    wg.Wait()

    return result
}

// bad code
// after refactoring "funcName" rename to "fetchRepositoryByID"
func fetchRepositoryByID(wg sync.WaitGroup, result []Repository, i int, id int) {
    wg.Add(1)

    go func(i, id int) {
        result[i] = Repository{
            ID:        id,
            StarCount: fetchStarCountByRepositoryID(id),
        }

        wg.Done()
    }(i, id)
}


go run ./examples/10-02-waitgroup-copy-err/main.go


repository          0 has   0 stars
repository          0 has   0 stars
repository          0 has   0 stars
complete 3 repositories by 0.000 seconds

Програма виконалась, але по результату в терміналі бачимо помилку (порівняйте з попереднім результатом).

Тепер дізнаємось, що скаже лінтер:

golangci-lint run ./examples/10-02-waitgroup-copy-err/main.go


examples/10-02-waitgroup-copy-err/main.go:33:23: copylocks: call of fetchRepositoryByID copies lock value: sync.WaitGroup contains sync.noCopy (govet)
        fetchRepositoryByID(wg, result, i, id)
                            ^
examples/10-02-waitgroup-copy-err/main.go:43:29: copylocks: fetchRepositoryByID passes lock by value: sync.WaitGroup contains sync.noCopy (govet)
func fetchRepositoryByID(wg sync.WaitGroup, result []Repository, i int, id int) {

golangci-lint успішно знайшов помилку після рефакторингу, тепер виправимо, додавши пропущений вказівник:

func fetchRepositoriesByIDs(ids []int) []Repository {
    var (
        result = make([]Repository, len(ids))
        wg     sync.WaitGroup
    )

    for i, id := range ids {
        fetchRepositoryByID(&wg, result, i, id)
    }

    wg.Wait()

    return result
}

// after refactoring "funcName" rename to "fetchRepositoryByID"
func fetchRepositoryByID(wg *sync.WaitGroup, result []Repository, i int, id int) {
    wg.Add(1)

    go func(i, id int) {
        result[i] = Repository{
            ID:        id,
            StarCount: fetchStarCountByRepositoryID(id),
        }

        wg.Done()
    }(i, id)
}


go run ./examples/10-03-waitgroup-nocopy/main.go


repository    1296269 has 909 stars
repository  265554657 has 737 stars
repository  264964189 has  93 stars
complete 3 repositories by 0.100 seconds


golangci-lint run ./examples/10-03-waitgroup-nocopy/main.go


(golangci-lint справедливо мовчить, бо помилка виправлена)

Особисто мені подобається такий варіант new(sync.WaitGroup) :

func fetchRepositoriesByIDs(ids []int) []Repository {
    var (
        result = make([]Repository, len(ids))
        wg     = new(sync.WaitGroup)
    )

    for i, id := range ids {
        fetchRepositoryByID(wg, result, i, id)
    }

    wg.Wait()

    return result
}

Схожа помилка можлива з sync.Mutex та іншими структурами.

В оригінальній статті також є згадка про цю помилку App Exits With Active Goroutines.

Для кращого розуміння вказівників в Go є пояснення Golang pointers explained, once and for all.

Розбухання ресурсів через defer в циклі

Defer — команда для відкладеного виконання дії перед завершенням основної функції. Якщо використовувати defer в циклі то відкладені дії будуть накопичуватись:

func ping(urls []string) {
    for _, url := range urls {
        var response, getErr = http.Get(url)
        if getErr != nil {
            // log error
            fmt.Println(getErr)

            continue
        }
        // bad code
        defer func(url string) {
            response.Body.Close()

            fmt.Printf("url %s response.Body.Close()\n", url)
        }(url)

        var content, readErr = ioutil.ReadAll(response.Body)
        if readErr != nil {
            fmt.Println(readErr)

            continue
        }

        fmt.Printf("url %s status %d size %d\n", url, response.StatusCode, len(content))
    }
}


go run ./examples/11-01-for-defer-err/main.go


url http://example.com/companies status 404 size 1256
url http://example.com/vacancies status 404 size 1256
url http://example.com/vacancies response.Body.Close()
url http://example.com/companies response.Body.Close()

Якщо defer в циклі звільняє ресурси, як в нашому прикладі, то функція на кожній ітерації буде захоплювати все більше пам’яті та навіть призвести до OOM.

IDE підсвічує defer в циклі як можливе протікання ресурсів, а що скаже golangci-lint :

golangci-lint run ./examples/11-01-for-defer-err/main.go


(golangci-lint мовчить)

Можна виправити, обгорнувши у функцію:

func ping(urls []string) {
    for _, url := range urls {
        func(url string) {
            var response, getErr = http.Get(url)
            if getErr != nil {
                // log error
                fmt.Println(getErr)

                return
            }
            defer func() {
                response.Body.Close()

                fmt.Printf("url %s response.Body.Close()\n", url)
            }()

            var content, readErr = ioutil.ReadAll(response.Body)
            if readErr != nil {
                // log error
                fmt.Println(readErr)

                return
            }

            fmt.Printf("url %s status %d size %d\n", url, response.StatusCode, len(content))
        }(url)
    }
}


go run ./examples/11-02-for-defer/main.go


url http://example.com/companies status 404 size 1256
url http://example.com/companies response.Body.Close()
url http://example.com/vacancies status 404 size 1256
url http://example.com/vacancies response.Body.Close()

Працює як треба, але читаючи такий код, може скластись відчуття, що функція в циклі зайва, тоді правильніше винести в окрему функцію:

func ping(urls []string) {
    for _, url := range urls {
        pingOne(url)
    }
}

func pingOne(url string) {
    // ...
}

На Stack Overflow є схожі питання: How does golang defer work in cycles? та defer in the loop — what will be better?.

Помилка перевикористання виділеної пам’яті

Для зменшення виділення пам’яті в Go, пам’ять перевикористовують через sync.Pool. Приклад використання sync.Pool в стандартній бібліотеці encoding/json , json.Marshal :

package json

var encodeStatePool sync.Pool

func newEncodeState() *encodeState {
    if v := encodeStatePool.Get(); v != nil {
        e := v.(*encodeState)
        e.Reset()

        // ...

        return e
    }
    return &encodeState{ptrSeen: make(map[interface{}]struct{})}
}

func Marshal(v interface{}) ([]byte, error) {
    e := newEncodeState()

    err := e.marshal(v, encOpts{escapeHTML: true})
    if err != nil {
        return nil, err
    }
    buf := append([]byte(nil), e.Bytes()...)

    encodeStatePool.Put(e)

    return buf, nil
}

Якщо ви дивлячись на цей код захотіли переписати («оптимізувати»):

// bad code
func Marshal(v interface{}) ([]byte, error) {
    e := newEncodeState()

    err := e.marshal(v, encOpts{escapeHTML: true})
    if err != nil {
        return nil, err
    }

    encodeStatePool.Put(e)

    return e.Bytes(), nil
}

Це й буде помилкою, яку розгядаємо, а саме використання пам’яті яку повернули в pool, щоб уникнути помилки і відбувалось повне копіювання через buf := append([]byte(nil), e.Bytes()...).

Щоб краще розібратись з помилкою, відтворимо її на простішому прикладі, і перевіримо, чи знаходить її go -race та golangci-lint.

Почнемо зі штучного прикладу Marshal

func Marshal(value uint64) []byte {
    var result []byte

    result = strconv.AppendUint(result, value, 10)

    return result
}

Але щоб побачити переваги sync.Pool, треба функція, яка використвує більше пам’яті. Напишемо її і запустимо тести:

package main

import (
    "github.com/stretchr/testify/require"
    "strconv"
    "testing"
)

func Marshal(value uint64) []byte {
    var result []byte

    result = strconv.AppendUint(result, value, 10)
    result = append(result, '_')
    result = strconv.AppendUint(result, value, 10)
    result = append(result, '_')
    result = strconv.AppendUint(result, value, 10)
    result = append(result, '_')
    result = strconv.AppendUint(result, value, 10)
    result = append(result, '_')
    result = strconv.AppendUint(result, value, 10)

    return result
}

func TestMarshal(t *testing.T) {
    const (
        expected = "18446744073709551615_18446744073709551615_18446744073709551615_18446744073709551615_18446744073709551615"
    )

    require.Equal(t, []byte(expected), Marshal(18446744073709551615))
}

var (
    fixtures = []uint64{
        18446744073709551615, // math.MaxUint64
        1844674407370955161,
        184467440737095516,
        18446744073709551,
        1844674407370955,
        184467440737095,
        18446744073709,
        1844674407370,
        184467440737,
        18446744073,
        1844674407,
        184467440,
        18446744,
        1844674,
        184467,
        18446,
        1844,
        184,
        18,
        1,
    }
    fixturesLength = len(fixtures)
)

func BenchmarkMarshal(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Marshal(fixtures[i%fixturesLength])
    }
}

func BenchmarkMarshalParallel(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        var i = 0

        for pb.Next() {
            Marshal(fixtures[i%fixturesLength])

            i++
        }
    })
}


go test ./examples/12-01-marshal/... -bench=. -benchmem


BenchmarkMarshal                 297 ns/op       144 B/op          3 allocs/op
BenchmarkMarshalParallel        54.7 ns/op       144 B/op          3 allocs/op
PASS
ok      gitlab.com/go-yp/fifty-shades-of-go/examples/12-01-marshal  2.870s

Оптимізуємо функцію, щоб завчасно виділяла пам’ять:

func Marshal(value uint64) []byte {
    const (
        maxUint64      = "18446744073709551615"
        uint64Size     = len(maxUint64)
        maxMarshalSize = uint64Size*5 + 4
    )

    var result = make([]byte, 0, maxMarshalSize)

    result = strconv.AppendUint(result, value, 10)
    result = append(result, '_')
    result = strconv.AppendUint(result, value, 10)
    result = append(result, '_')
    result = strconv.AppendUint(result, value, 10)
    result = append(result, '_')
    result = strconv.AppendUint(result, value, 10)
    result = append(result, '_')
    result = strconv.AppendUint(result, value, 10)

    return result
}


go test ./examples/12-02-marshal-preallocate/... -bench=. -benchmem


BenchmarkMarshal                 229 ns/op       112 B/op          1 allocs/op
BenchmarkMarshalParallel        37.0 ns/op       112 B/op          1 allocs/op
PASS
ok      gitlab.com/go-yp/fifty-shades-of-go/examples/12-01-marshal-preallocate  2.824s

Як бачимо, число алокацій зменшилось, що ж тепер підключемо sync.Pool і побачимо помилку:

package main

import (
    "github.com/stretchr/testify/require"
    "strconv"
    "sync"
    "testing"
)

var (
    pool sync.Pool
)

func bytesGet() []byte {
    var value = pool.Get()

    if value != nil {
        var buffer = value.([]byte)

        // reset
        return buffer[:0]
    }

    return nil
}

func bytesPut(value []byte) {
    pool.Put(value)
}

func Marshal(value uint64) []byte {
    var buffer = bytesGet()

    buffer = strconv.AppendUint(buffer, value, 10)
    buffer = append(buffer, '_')
    buffer = strconv.AppendUint(buffer, value, 10)
    buffer = append(buffer, '_')
    buffer = strconv.AppendUint(buffer, value, 10)
    buffer = append(buffer, '_')
    buffer = strconv.AppendUint(buffer, value, 10)
    buffer = append(buffer, '_')
    buffer = strconv.AppendUint(buffer, value, 10)

    // bad code
    bytesPut(buffer)

    return buffer
}

func TestMarshal(t *testing.T) {
    const (
        expected1 = "12_12_12_12_12"
        expected2 = "255_255_255_255_255"
    )

    var (
        actual1 = Marshal(12)
        actual2 = Marshal(255)
    )

    t.Logf("actual1 %s", actual1)
    t.Logf("actual2 %s", actual2)

    require.Equal(t, []byte(expected1), actual1)
    require.Equal(t, []byte(expected2), actual2)
}


go test ./examples/12-03-marshal-sync-pool-err/... -v


=== RUN   TestMarshal
    TestMarshal: marshal_test.go:60: actual1 255_255_255_25
    TestMarshal: marshal_test.go:61: actual2 255_255_255_255_255
    TestMarshal: marshal_test.go:63:
            Error Trace:    marshal_test.go:63
            Error:          Not equal:
                            expected: []byte{0x31, 0x32, 0x5f, 0x31, 0x32, 0x5f, 0x31, 0x32, 0x5f, 0x31, 0x32, 0x5f, 0x31, 0x32}
                            actual  : []byte{0x32, 0x35, 0x35, 0x5f, 0x32, 0x35, 0x35, 0x5f, 0x32, 0x35, 0x35, 0x5f, 0x32, 0x35}
--- FAIL: TestMarshal (0.00s)
FAIL
FAIL    gitlab.com/go-yp/fifty-shades-of-go/examples/12-03-marshal-sync-pool-err    0.002s
FAIL

actual1 255_255_255_25 і є помилкою, бо очікували actual1 12_12_12_12_12. Це відбулось через перезаписування пам’яті, яку повернули в pool. Перевіремо через golangci-lint :

golangci-lint run ./examples/12-03-marshal-sync-pool-err/marshal_test.go


examples/12-03-marshal-sync-pool-err/marshal_test.go:28:11: SA6002: argument should be pointer-like to avoid allocations (staticcheck)
    pool.Put(value)
             ^

golangci-lint сповіщає про іншу помилку, але мовчить стосовно справжньої помилки.

Спробуємо -race :

go test -race ./examples/12-03-marshal-sync-pool-err/... -v -run=$^ -bench=. -benchmem -benchtime=3s


BenchmarkMarshal                 2115 ns/op       75 B/op          1 allocs/op
BenchmarkMarshalParallel         465 ns/op        75 B/op          1 allocs/op
PASS
ok      gitlab.com/go-yp/fifty-shades-of-go/examples/12-03-marshal-sync-pool-err    9.922s

Тепер виправимо функцію Marshal :

func Marshal(value uint64) []byte {
    var buffer = bytesGet()

    buffer = strconv.AppendUint(buffer, value, 10)
    buffer = append(buffer, '_')
    buffer = strconv.AppendUint(buffer, value, 10)
    buffer = append(buffer, '_')
    buffer = strconv.AppendUint(buffer, value, 10)
    buffer = append(buffer, '_')
    buffer = strconv.AppendUint(buffer, value, 10)
    buffer = append(buffer, '_')
    buffer = strconv.AppendUint(buffer, value, 10)

    // var result = append([]byte(nil), buffer...)
    // alternative
    var result = make([]byte, len(buffer))
    copy(result, buffer)

    bytesPut(buffer)

    return result
}


go test ./examples/12-04-marshal-sync-pool/... -v -bench=. -benchmem


=== RUN   TestMarshal
    TestMarshal: marshal_test.go:65: actual1 12_12_12_12_12
    TestMarshal: marshal_test.go:66: actual2 255_255_255_255_255
--- PASS: TestMarshal (0.00s)
goos: linux
goarch: amd64
pkg: gitlab.com/go-yp/fifty-shades-of-go/examples/12-04-marshal-sync-pool
BenchmarkMarshal                 229 ns/op        96 B/op          2 allocs/op
BenchmarkMarshalParallel        50.3 ns/op        96 B/op          2 allocs/op
PASS
ok      gitlab.com/go-yp/fifty-shades-of-go/examples/12-04-marshal-sync-pool    2.453s

Тести пройшли успішно, а тепер виправимо іншу помилку, на яку вказує golangci-lint :

examples/12-04-marshal-sync-pool/marshal_test.go:28:11: SA6002: argument should be pointer-like to avoid allocations (staticcheck)
    pool.Put(value)
             ^



var (
    pool sync.Pool
)

type Buffer struct {
    b []byte
}

func bytesGet() *Buffer {
    var value = pool.Get()

    if value != nil {
        var buffer = value.(*Buffer)

        // reset
        buffer.b = buffer.b[:0]

        return buffer
    }

    return new(Buffer)
}

func bytesPut(value *Buffer) {
    pool.Put(value)
}

func Marshal(value uint64) []byte {
    var buffer = bytesGet()

    buffer.b = strconv.AppendUint(buffer.b, value, 10)
    buffer.b = append(buffer.b, '_')
    buffer.b = strconv.AppendUint(buffer.b, value, 10)
    buffer.b = append(buffer.b, '_')
    buffer.b = strconv.AppendUint(buffer.b, value, 10)
    buffer.b = append(buffer.b, '_')
    buffer.b = strconv.AppendUint(buffer.b, value, 10)
    buffer.b = append(buffer.b, '_')
    buffer.b = strconv.AppendUint(buffer.b, value, 10)

    // var result = append([]byte(nil), buffer.b...)
    // alternative
    var result = make([]byte, len(buffer.b))
    copy(result, buffer.b)

    bytesPut(buffer)

    return result
}


go test ./examples/12-05-marshal-sync-pool/... -v -bench=. -benchmem


BenchmarkMarshal                 205 ns/op        64 B/op          1 allocs/op
BenchmarkMarshalParallel        47.1 ns/op        64 B/op          1 allocs/op
PASS
ok      gitlab.com/go-yp/fifty-shades-of-go/examples/12-05-marshal-sync-pool    2.737s

А тепер приклад помилки перевикористання з використанням бібліотеки fasthttp , яка під капотом має sync.Pool :

package main

import (
    "fmt"
    "github.com/valyala/fasthttp"
)

func get(url string) ([]byte, error) {
    var (
        request  = fasthttp.AcquireRequest()
        response = fasthttp.AcquireResponse()
    )

    defer func() {
        fasthttp.ReleaseRequest(request)
        fasthttp.ReleaseResponse(response)
    }()

    request.SetRequestURI(url)

    var err = fasthttp.Do(request, response)
    if err != nil {
        return nil, err
    }

    // bad code
    return response.Body(), nil
}

func main() {
    var response1, err1 = get("https://api.ipify.org?format=json")
    if err1 != nil {
        fmt.Printf("err1 %+v\n", err1)

        return
    }
    fmt.Printf("response1 %s\n", string(response1))

    var response2, err2 = get("https://api.ipify.org?format=xml")
    if err2 != nil {
        fmt.Printf("err2 %+v\n", err2)

        return
    }

    fmt.Printf("response1 %s\n", string(response1))
    fmt.Printf("response2 %s\n", string(response2))
}



go run ./examples/12-06-sync-pool-fasthttp-err/main.go


response1 {"ip":"127.0.0.1"}
response1 127.0.0.17.0.0.1"}
response2 127.0.0.1

І go test -race і golangci-lint пропустили помилку.

Опис помилки як маленька стаття, отож навчальні матеірали: justforfunc #37: sync.Pool from the pool.

Затінені зміні (shadowed variables)

Затінені зміні також є помилкою в Go. Найпростіший приклад взятий зі статті Shadowed variables:

package main

import "fmt"

func main() {
    n := 0

    if true {
        n := 1
        n++
    }

    fmt.Println(n)
}


go run ./examples/13-01-shadowed-variables-err/main.go


0


golangci-lint run ./examples/13-01-shadowed-variables-err/main.go


examples/13-01-shadowed-variables-err/main.go:10:3: ineffectual assignment to `n` (ineffassign)
        n++
        ^

golangci-lint знайшов затінення, але є складніші затінення, які golangci-lint пропускає. Штучний приклад:

package main

import (
    "fmt"
    "math"
)

type Employee struct {
    title string
}

func (e *Employee) Title() string {
    return e.title
}

type Vacancy struct {
    title       string
    description string
    salary      [2]uint64
}

func (v *Vacancy) Title() string {
    return v.title
}

func main() {
    dump(Vacancy{"Go Developer", "highload", [2]uint64{math.MaxUint32, math.MaxUint64}})
    dump(Employee{"Go Developer"})
}

func dump(value interface{}) {
    if value, ok := value.(Vacancy); ok {
        fmt.Println("vacancy", value.title, value.description, value.salary)
    } else {
        fmt.Println("title", value.Title())
    }
}


go run ./examples/13-02-shadowed-variables-named-return/main.go


vacancy Go Developer highload [4294967295 18446744073709551615]
title


golangci-lint run ./examples/13-02-shadowed-variables-named-return/main.go


(golangci-lint мовчить)

Якщо переписати dump , то стає зрозуміло, чому така відповідь в консолі:

func dump(value interface{}) {
    {
        value, ok := value.(Vacancy)

        if ok {
            fmt.Println("vacancy", value.title, value.description, value.salary)
        } else {
            fmt.Println("title", value.Title())
        }
    }
}

Опис помилки в оригінальній статті Accidental Variable Shadowing.

Рекомендація: написання складних перевірок

Писати тести на кожну дію — занадто, але можна писати різні перевірки в коді, перевірка версії через константу:

import (
    proto "github.com/gogo/protobuf/proto"
)

// proto package needs to be updated.
const _ = proto.GoGoProtoPackageIsVersion3 // please upgrade the proto package

Перевірка на тип:

type Entity interface {
    Columns() []string
    Values() []interface{}
}

type VacancyView struct {
    Time      int32
    VacancyID uint32
    Source    uint8 // list, page, email, bot
}

func (v VacancyView) Columns() []string {
    return []string{
        "time",
        "vacancy_id",
        "source",
    }
}

func (v *VacancyView) Values() []interface{} {
    return []interface{}{
        v.Time,
        v.VacancyID,
        v.Source,
    }
}

var (
    _ Entity = new(VacancyView)
)

init

func init() {
    assertEntity(&VacancyView{})
}

func assertEntity(entity Entity) {
    var (
        columns = len(entity.Columns())
        values  = len(entity.Values())
    )

    if columns != values {
        panic(fmt.Sprintf("entity %T columns %d values %d", entity, columns, values))
    }
}

Помилки, які пропустив описати

Дві години відладки коду можуть зберегти вам п’ять хвилин читання документації. Навіть пропущений ticker.Stop() призводить до протікання горутин Ticker:

Stop the ticker to release associated resources.

А ще пропущу описати, що зустрічав в коді канали, які використовувались як мютекс, екзотичний варіант використання.

А ще пропущу описати, що atomic first , бо зустрічав приклади коли використовувати мютекси для звичайних лічильників.

Виправлення помилок Go

У цій статті писав, що в Go відсутні дженерики і це виправлять, як колись виправили планувальник (scheduler) щоб наступний приклад коду міг завершитись:

package main

import "fmt"

func main() {
    done := false

    go func(){
        done = true
    }()

    for !done {
    }
    fmt.Println("done!")
}

Цю помилку з оригінальної статті Preemptive Scheduling вже виправили в Go.

Хибні спрацьовування, налаштування golangci-lint для найменування, nolint

Як знаходити лінтером попередження вже розглянули, а тепер розглянемо як заховати ці попередження. Достатньо додати коментар nolint :

// nolint:deadcode,unused
func Errorf(format string, a ...interface{}) {
    log.Printf(e+" "+format, a)
}

Або додати nolint біля конкретної лінії коду:

func AppendGzipBytesLevel(dst, src []byte, level int) []byte {
    w := &byteSliceWriter{dst}
    WriteGzipLevel(w, src, level) //nolint:errcheck
    return w.b
}

А список папок можна заховати через налаштування .golangci.yml

linters:
  enable-all: true
  disable:
    - goimports
    - lll
    - gochecknoinits
    - gochecknoglobals

run:
  skip-dirs-use-default: false
  skip-dirs:
    - vendor

issues:
  exclude-rules:
    - text: "composite literal uses unkeyed fields"
      linters:
        - govet
    - text: "should drop = 0 from declaration of var"
      linters:
        - golint



golangci-lint run -v


...
INFO [config_reader] Used config file .golangci.yml
...

Налаштування дуже корисні, щоб відключити перевірку найменування для автозгенерованих файлів.

Висновки

Ця стаття показала на прикладах, що кожен інструмент знаходить тільки частину помилок а інші пропускає, тому важливо запускати і код з go run -race і тести з go test -race , запускати статичні аналізатори коду, проводити code review і дивитись метрики.

Епілог

Якщо ви вже читали мої статті і поспішаєте написати в коментарях, що я використовував приклади з попередніх своїх статей, так і є, пишіть і критикуйте, але врахуйте, що це концепція Go: краще скопіювати функцію, ніж додавати в залежність ще одну бібліотеку.

Мова програмування Go розвивається в Україні, це доводить рейтинг мов програмування 2021, де кожен восьмий розробник в цьому році збирається вивчити Go, тому в українській Go спільноті буде поповнення, для якого дана стаття буде корисною, тому діліться з новенькими.

В статті є приховані посилання на художні твори, якщо знайшли, то напишіть про це в коментарях.

Схожі статті та навчальні матеріали

  • 50 Shades of Go: Traps, Gotchas, and Common Mistakes for New Golang Devs
  • Golang pointers explained, once and for all
  • Some tricks and tips for using for range in Go
  • justforfunc #37: sync.Pool from the pool
  • How to avoid Go gotchas
  • Поради, як прокачати junior Go