Go言語(Golang)で引数に渡された関数の実行タイミングは、引数があるかないかで変わる

こんにちは。てぃろです。

最近仕事が変わってGo言語の実装を担当するようになりました。

そこでGo言語で引数に関数を渡している箇所があったので、具体的にどのような挙動になるのか実験して確かめてみることにしました。

実験したのは以下の2パターンについてです。

  • パターン1:引数に渡す関数に引数がある
  • パターン2:引数に渡す関数に引数がない

主にその実行タイミングを知りたかったのはパターン2なのですが、実験の過程で1つ目もやることになったので、こちらの結果も示します。なお、本記事では関数を引数に渡すケースについてあらゆるパターンを網羅することはなく、今回実験に使ったパターンのみについて考察します。

ちなみに、Goのバージョンは1.18.3です。

パターン1:引数に渡す関数に戻り値がある

では、こちらもまずはサンプルコードを示します。

package main

import (
	"fmt"
)

func handleAdd(fn func(int, int) int, x int, y int) (res int) {
	fmt.Println("start handle add")
	if fn != nil {
		res = fn(x, y)
		return
	}
	fmt.Println("end handle add")
	return
}

func add(x int, y int) int {
	fmt.Println("start add")
	return x + y
}

func main() {
	fmt.Println(handleAdd(add, 1, 2))
}

実行結果は以下のようになります。

E:\development\repositories\tmp\go-sample>go run main2.go
start handle add
start add
3

こちらのパターンではadd()が実行されたのは10行目の時点でした。こちらはadd()が取るべき引数を渡しているのは10行目だけですのでそこで実行されるだろうということは直観的に理解できますね。

9行目のif文の条件についても、fnとして渡された関数が評価(実行)されるわけではなく、fnという変数そのものがnilではないかを評価しているに過ぎないのでしょう(だから実行されてない)。

パターン1はこのように非常に直感的でわかりやすい結果でしたが、実はパターン2は私にとっては少し直観的ではなかったので、途中でパターン1の実装で試してみて関数を引数に渡すということの理解を進めることにしたという経緯でした。

では、引き続きパターン2を見ていきます。

パターン2:引数に渡す関数に引数がない

こちらも実験に使ったサンプルコードを最初に示します。

package main

import (
	"fmt"
)

func handleHello(err error) {
	fmt.Println("Start handle Hello")
	if err != nil {
		fmt.Println("first err")
		fmt.Println(err)
		if err != nil {
			fmt.Println("second err")
			fmt.Println(err)
			return
		}
		return
	}
	fmt.Println("End handle Hello")
}

func hello() error {
	fmt.Println("Hello")
	return fmt.Errorf("Error")
}

func main() {
	handleHello(hello())
}

実行結果は以下のようになります。

E:\development\repositories\tmp\go-sample>go run main.go  
Hello
Start handle Hello
first err
Error
second err
Error

この結果から、hello()は28行目で実行されているということがわかります。

handleHello()を呼び出したときに8行目の”Start handle Hello”が表示されると思いきや、それよりも先にhello()の中にある”Hello”という文言が表示されています。つまり、実行自体は28行目でhandleHello()の呼び出し時点であったことがわかります。

さらに見ていくと、hello()の返り値としての”Error”という文言は11行目や14行目で表示されていますが、”Hello”という文言が何度も表示されていません。そのことからhello()の実行結果は保存されており、何度もhello()が実行されているわけではないことがわかります。

今回の実験では特に知りたかったのは、errが評価されるところでした。パッと見るとerrの評価がされるところで何度もhello()が実行される危険性があるのではないか?と思ったのですが、実験結果の通り実行は一回だけでした。

最後に

今回はGo言語で関数を引数に渡す場合に、渡される関数のパターンでその動きがどう変わるかを実験してみました。

パターン2は、私にとってはやや直観に反していましたが、すごく納得できる仕様のように思います。ただ、直観に反していることに変わりはないので、個人的にはあまり使いたくない方法なように思います。

なのでパターン2のケースは、例えばmain()を↓のようにしてしまうとかのほうが、可読性が高くていいんじゃないかと思います。

func main() {
	err := hello()
	if err != nil {
		handleHello(err)
	}
}

これならhello()が実行されるタイミングは明白ですし、errを処理するロジックをわざわざ見えづらいhandleHello()の中に書かずに済みます。

今回はサンプルコードなので、そもそも…という話もあるかと思いますが、一種のケーススタディとして楽しんでもらえたらよいかと思います。