Використання Defer у Go

by

Привіт, мене звуть Ярослав. Уже рік я займаюся Go-розробкою в компанії Evrius. У цій статті опишу добре відомі приклади використання команди defer у Go та покритикую, коли defer зайвий. Відповідно, початок статті буде розрахований на початківців, а продовження — на вже досвідчених.

Defer і порядок у коді

Defer — команда для відкладеного виконання дії перед завершенням основної функції. Defer схожий на пружину, яка в люту зиму зачиняє відчинені двері.

Популярний приклад, це закриття файлу або закриття з’єднання до БД:

func FileOperationsExample() error {
f, err := os.Create("/tmp/defer.txt")
if err != nil {
return err
}
defer f.Close()

// запис у файл або інші операції

return nil
}

Ще один приклад для блокування та розблокування:

import "sync"

type CurrencyRateService struct {
data map[string]map[string]float64
m    sync.RWMutex
}

func (s *CurrencyRateService) Update(data map[string]map[string]float64) {
s.m.Lock()
defer s.m.Unlock()

s.data = data
}

func (s *CurrencyRateService) Get(fromCurrencyCode, toCurrencyCode string) float64 {
s.m.RLock()
defer s.m.RUnlock()

return s.data[fromCurrencyCode][toCurrencyCode]
}

Це актуально в прикладах, складніших за CurrencyRateService, де ліпше:

func (s *CurrencyRateService) Update(data map[string]map[string]float64) {
s.m.Lock()
s.data = data
s.m.Unlock()
}

func (s *CurrencyRateService) Get(fromCurrencyCode, toCurrencyCode string) float64 {
s.m.RLock()
rate := s.data[fromCurrencyCode][toCurrencyCode]
s.m.RUnlock()

return rate
}

Defer та доступ до результату функції

Для прикладу візьмімо просту функцію, в якої є іменована результатна змінна (named return values):

func ReturnOne() (result int) {
result = 1

return
}

використаємо defer, щоб змінити результат:

func RewriteReturnOne() (result int) {
defer func() {
result = 2
}()

result = 1

return
}

func TestRewriteReturnOne(t *testing.T) {
assert.Equal(t, 2, RewriteReturnOne())
}
func RewriteReturnOneWithoutAssign() (result int) {
defer func() {
result = 3
}()

return 1
}

func TestRewriteReturnOneWithoutAssign(t *testing.T) {
assert.Equal(t, 3, RewriteReturnOneWithoutAssign())
}

Ці приклади зрозумілі, також defer має доступ до значення, що було встановлене перед поверненням:

func ModifyReturnOneWithoutAssign() (result int) {
defer func() {
result = result * 5
}()

return 2
}

func TestModifyReturnOneWithoutAssign(t *testing.T) {
assert.Equal(t, 10, ModifyReturnOneWithoutAssign())
}

Порядок виконання defer у функції

Зазвичай у прикладах, щоб показати порядок виконання, використовують fmt.Println для легкого запуску в playground.

Та буде простіше показати порядок виконання, використовуючи тести. Ось приклад:

func OneDeferOrder() (result []string) {
result = append(result, "first")

defer func() {
result = append(result, "first defer")
}()

result = append(result, "second")

return result
}

І тест покаже очікуваний результат:

func TestOneDeferOrder(t *testing.T) {
var actual = OneDeferOrder()

assert.Equal(
t,
actual,
[]string{
"first",
"second",

"first defer",
},
)
}

Допишемо ще один defer:

func DoubleDeferOrder() (result []string) {
result = append(result, "first")
defer func() {
result = append(result, "first defer")
}()

result = append(result, "second")
defer func() {
result = append(result, "second defer")
}()

result = append(result, "third")

return result
}

І тест, який покаже, що порядок виконання defer зворотний до їх додавання в список на виконання:

func TestDoubleDeferOrder(t *testing.T) {
var order = DoubleDeferOrder()

assert.Equal(
t,
order,
[]string{
"first",
"second",
"third",

"second defer",
"first defer",
},
)
}

Це як розмотування клубка ресурсів, які залежать від попередніх, або ж LIFO.

Defer та ланцюг викликів методів

Підготуємо структуру для збереження стану:

type State struct {
values []string
}

func (s *State) Append(value string) *State {
s.values = append(s.values, value)

return s
}

func (s *State) Values() []string {
return s.values
}

Та функцію, що буде використовувати ланцюг викликів:

func OnlyLastHandleDefer(state *State) {
state.Append("first")

defer state.
Append("first defer — first call").
Append("first defer — second call").
Append("first defer — last call")

state.Append("second")
}

Тест покаже, що тільки останній виклик буде відкладеним:

func TestOnlyLastHandleDefer(t *testing.T) {
var state = new(State)

OnlyLastHandleDefer(state)

assert.Equal(
t,
state.Values(),
[]string{
"first",
"first defer — first call",
"first defer — second call",
"second",
"first defer — last call",
},
)
}

Обернувши у функцію, зробимо відкладено для всіх викликів у ланцюжку:

func OnlyLastHandleDeferWrap(state *State) {
state.Append("first")

defer func() {
state.
Append("first defer — first call").
Append("first defer — second call").
Append("first defer — last call")
}()

state.Append("second")
}

func TestOnlyLastHandleDeferWrap(t *testing.T) {
var state = new(State)

OnlyLastHandleDeferWrap(state)

assert.Equal(
t,
state.Values(),
[]string{
"first",
"second",
"first defer — first call",
"first defer — second call",
"first defer — last call",
},
)
}

Defer і розрахунок аргументів

Підготуємо лічильник:

import (
"strconv"
)

type StringCounter struct {
value uint64
}

func (c *StringCounter) Next() string {
c.value += 1

var next = c.value

return strconv.FormatUint(next, 10)
}

Напишімо тест і функцію, щоб показати, що аргументи будуть розраховані відразу:

func CallInside(state *State) {
var counter = new(StringCounter)

state.Append("first call " + counter.Next())

defer state.Append("first defer call " + counter.Next())

state.Append("second call " + counter.Next())
}

func TestCallInside(t *testing.T) {
var state = new(State)

CallInside(state)

assert.Equal(
t,
[]string{
"first call 1",
"second call 3",
"first defer call 2",
},
state.Values(),
)
}

Дія counter.Next() була виконана відразу, тому «first defer call 2».

Якщо обернути у функцію, то отримаємо очікуваний результат:

func CallInsideWrap(state *State) {
var counter = new(StringCounter)

state.Append("first call " + counter.Next())

defer func() {
state.Append("first defer call " + counter.Next())
}()

state.Append("second call " + counter.Next())
}

func TestCallInsideWrap(t *testing.T) {
var state = new(State)

CallInsideWrap(state)

assert.Equal(
t,
[]string{
"first call 1",
"second call 2",
"first defer call 3",
},
state.Values(),
)
}

Повернення помилок і panic

Під час виконання програми можуть відбуватися різноманітні сподівані помилки, у таких випадках у Golang помилка повертається в результаті функції.

У стандартних Golang-бібліотеках повно прикладів повернення помилок, створення файлу чи запис у файл, або найпростіший приклад:

package strconv

func ParseBool(str string) (bool, error) {
switch str {
case "1", "t", "T", "true", "TRUE", "True":
return true, nil
case "0", "f", "F", "false", "FALSE", "False":
return false, nil
}
return false, syntaxError("ParseBool", str)
}

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

Як приклад, спроба викликати метод до ініціалізації:

import (
"database/sql"
"github.com/stretchr/testify/assert"
"testing"
)

func PanicNilPointer(connection *sql.DB) {
_ = connection.Ping()
}

func TestPanicNilPointer(t *testing.T) {
var connection *sql.DB

PanicNilPointer(connection)
}
panic: runtime error: invalid memory address or nil pointer dereference

Також panic можна викликати в коді за допомогою команди panic.

Як приклад, у стандартному пакеті bytes функція Repeat викликає panic під час перевірки аргументів на коректність.

package bytes

func Repeat(b []byte, count int) []byte {
if count == 0 {
return []byte{}
}
// Since we cannot return an error on overflow,
// we should panic if the repeat will generate
// an overflow.
// See Issue golang.org/issue/16237.
if count < 0 {
panic("bytes: negative Repeat count")
} else if len(b)*count/count != len(b) {
panic("bytes: Repeat count causes overflow")
}

nb := make([]byte, len(b)*count)
bp := copy(nb, b)
for bp < len(nb) {
copy(nb[bp:], nb[:bp])
bp *= 2
}
return nb
}

Також є функції, що починаються зі слова Must і перетворюють повернення помилки на panic:

package regexp

func MustCompile(str string) *Regexp {
regexp, err := Compile(str)
if err != nil {
panic(`regexp: Compile(` + quote(str) + `): ` + err.Error())
}
return regexp
}

Recover, або відновлення після panic

Recover is a built-in function that regains control of a panicking goroutine. Recover is only useful inside deferred functions. During normal execution, a call to recover will return nil and have no other effect. If the current goroutine is panicking, a call to recover will capture the value given to panic and resume normal execution. Source

Recover — вбудована функція для відновлення поточної горутини під час panic, корисна тільки в парі з defer.

Recover повертає значення, передане під час виклику panic або nil.

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

func TestRecover(t *testing.T) {
var expect interface{}

var actual = recover()

assert.Equal(t, true, expect == actual)
}

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

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

func WrapRecovery(state *State) {
state.Append("first")
defer func() {
if err := recover(); err != nil {
if errorMessage, ok := err.(string); ok {
state.Append("first defer — recover string panic: " + errorMessage)
} else {
state.Append("first defer — recover panic")
}
} else {
state.Append("first defer — without panic")
}
}()

state.Append("second")
defer func() {
if err := recover(); err != nil {
if errorMessage, ok := err.(string); ok {
state.Append("second defer — recover string panic: " + errorMessage)
} else {
state.Append("second defer — recover panic")
}
} else {
state.Append("second defer — without panic")
}
}()

state.Append("third")
defer func() {
state.Append("third defer — without recover")
}()

panic("catch me")
}

func TestWrapRecovery(t *testing.T) {
var state = new(State)

WrapRecovery(state)

assert.Equal(
t,
[]string{
"first",
"second",
"third",
"third defer — without recover",
"second defer — recover string panic: catch me",
"first defer — without panic",
},
state.Values(),
)
}

Щоб ви знали, у panic можна передати nil, але ліпше передавати string або error.

package main

func main() {
defer func() {
if recover() != nil {
panic("non-nil recover")
}
}()
panic(nil)
}

У разі виникнення panic, усередині вкладених функцій defer відпрацює сподівано, приклад:

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

func NestedPanic(state *State) {
state.Append("0 level")
defer func() {
if err := recover(); err != nil {
if errorMessage, ok := err.(string); ok {
state.Append("0 level — recover string panic: " + errorMessage)
} else {
state.Append("0 level — recover panic")
}
} else {
state.Append("0 level — without panic")
}
}()

NestedPanic1Level(state)
}

func NestedPanic1Level(state *State) {
state.Append("1 level")
defer func() {
state.Append("1 level — defer")
}()

NestedPanic2Level(state)
}

func NestedPanic2Level(state *State) {
state.Append("2 level")
defer func() {
state.Append("2 level — defer")
}()

panic("2 level — panic")
}

func TestNestedPanic(t *testing.T) {
var state = new(State)

NestedPanic(state)

assert.Equal(
t,
[]string{
"0 level",
"1 level",
"2 level",
"2 level — defer",
"1 level — defer",
"0 level — recover string panic: 2 level — panic",
},
state.Values(),
)
}

Panic усередині defer

Розгляньмо, що буде, коли під час відновлення після panic знову відбудеться panic:

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

func PanicInsideRecover(state *State) {
state.Append("first")
defer func() {
if err := recover(); err != nil {
if errorMessage, ok := err.(string); ok {
state.Append("first defer — recover string panic: " + errorMessage)
} else {
state.Append("first defer — recover panic")
}
} else {
state.Append("first defer — without panic")
}
}()

state.Append("second")
defer func() {
if err := recover(); err != nil {
if errorMessage, ok := err.(string); ok {
state.Append("second defer — recover string panic: " + errorMessage)
} else {
state.Append("second defer — recover panic")
}
} else {
state.Append("second defer — without panic")
}

panic("inside defer")
}()

panic("catch me")
}

func TestPanicInsideRecover(t *testing.T) {
var state = new(State)

PanicInsideRecover(state)

assert.Equal(
t,
[]string{
"first",
"second",
"second defer — recover string panic: catch me",
"first defer — recover string panic: inside defer",
},
state.Values(),
)
}

Сподівано буде відновлений на наступному defer з recover у поточній горутині.

Panic і сигнатура функції

Якщо результат, що повертається в тілі функції, відрізняється від сигнатури функції, то Golang повідомить про помилку під час компіляції.

Простіше побачити це на прикладах, де будуть повідомлення про помилку:

func ReturnSignatureIntEmptyBody() int {

}

func ReturnSignatureNamedIntEmptyBody() (result int) {

}

func ReturnSignatureEmptyIntBody() {
return 0
}

А цей приклад з panic успішно компілюється:

func ReturnSignatureIntPanicBody() int {
panic("implement me")
}

Відповідно, panic можна використовувати під час побудови структури програми, а вже потім робити реалізацію.

Епілог та особливості

Усе, що описано — приклади коду, тести — сподіване для тих, хто вже розробляє на Go, і питання: «А навіщо писати цю статтю?» теж очікуване. Перша причина: приклади з panic, defer, recover часто поверхневі, тому я захотів зібрати їх разом і протестувати. Друга причина в тому, що забувають про слабкі сторони.

Recover тільки для поточної горутини

Якщо взяти приклад, де panic відбудеться в іншій горутині без recover, то програма завершить своє виконання (та повідомить про panic):

package go_defer_reserach

import (
"sync"
"time"
)

type PanicFunctionState struct {
Completed bool
}

func InsideGorountinePanic(state *PanicFunctionState, n, after int) {
var wg = new(sync.WaitGroup)

for i := 1; i <= n; i++ {
wg.Add(1)

go func(i int) {
defer wg.Done()

panicAfterN(i, after)
}(i)
}

wg.Wait()

state.Completed = true

return
}


func panicAfterN(i, after int) {
time.Sleep(time.Millisecond)

if i%after == 0 {
panic("i%after == 0")
}
}
package main

import (
"fmt"
go_defer_reserach "gitlab.com/go-yp/go-defer-reserach"
)

func main() {
var state = new(go_defer_reserach.PanicFunctionState)

defer func() {
fmt.Printf("panic state `%t` after\n", state.Completed)
}()

fmt.Printf("panic state `%t` before\n", state.Completed)

go_defer_reserach.InsideGorountinePanic(state, 25, 20)
}

Запустивши цей приклад 10+ разів, переважно отримував:

panic state `false` before
panic: i%after == 0

і 1-2 рази отримував

panic state `false` before
panic state `true` after
panic: i%after == 0

У цьому прикладі коду з defer:

wg.Add(1)

go func(i int) {
    defer wg.Done()

    panicAfterN(i, after)
}(i)

жодної значної переваги, порівнюючи з кодом без defer:

wg.Add(1)
go func(i int) {
    panicAfterN(i, after)

    wg.Done()
}(i)

І навпаки, хоч код з defer і виконується повільніше (до версії Go 1.14), але виграш у ~100 наносекунд — малий, порівнюючи з тим, що завдання, які розпаралелили, може виконуватися мілісекунди.

os.Exit завершує програму відразу та ігнорує defer:

package main

import (
"fmt"
"os"
)

// os.Exit ignore defer, output will "first call"
func main() {
fmt.Println("first call")

defer fmt.Println("first defer call")

os.Exit(0)
}

Як і очікували first call

Коли recover не працює в поточній горутині:

go func(i int) {
defer wg.Done()
defer recover()

panicAfterN(i, after)
}(i)

А так працює, як і сподіваємося:

go func(i int) {
defer wg.Done()
defer func() {
recover()
}()

panicAfterN(i, after)
}(i)

Дякую за увагу!

P. S. Ця стаття написана як продовження вже відомої Defer, Panic, and Recover. Якщо захочете перевірити приклади й тести, то заходьте до репозиторію.

Темы: Go, tech