Сучасна диджитал-освіта для дітей — безоплатне заняття в GoITeens ×
Mazda CX 5
×

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 спільноті буде поповнення, для якого дана стаття буде корисною, тому діліться з новенькими.

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

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

👍ПодобаєтьсяСподобалось15
До обраногоВ обраному13
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Очень грамотный и сильный материал — спасибо огромное за Ваш труд !

Підписатись на коментарі