1. ホーム
  2. スクリプト・コラム
  3. ゴラン

囲碁におけるクロージャーの基本原理

2022-02-14 21:20:02

1. クロージャとは?

関数内部で外部ローカル変数を参照する関数をクロージャと呼びます。

例えば、この下のコードでは >>> def fun(arg): pass; >>> fun(1,arg=3); Traceback (most recent call last): File "<pyshell#15>", line 1, in <module> fun(1,arg=3); TypeError: fun() got multiple values for argument 'arg' >>> 関数が返す無名関数は adder 関数のローカル変数 sum であれば、その関数はクロージャである。

package main 
 
import "fmt" 
 
func adder() func(int) int { 
    sum := 0 
    return func(x int) int { 
        sum += x 
        return sum 
    } 
} 



また、このクロージャで参照される外部ローカル変数は adder 関数が戻り、スタックから破棄されます。

この関数を呼び出してみたところ、呼び出すたびに sum の値がクロージャ関数に保持され、使用されます。

func main() { 
     valueFunc:= adder() 
     fmt.Println(valueFunc(2)) // output: 2 
     fmt.Println(valueFunc(2)) // output: 4 
} 



2. 複雑なクロージャーシナリオ

クロージャを書くのは比較的簡単ですが、簡単なクロージャ関数が書けるだけではダメです。クロージャの本当の仕組みを理解していないと、複雑なクロージャのシナリオでは、関数の実行ロジックを見誤ってしまうことがあります。

<ブロッククオート

さっそくですが、こんな例はいかがでしょうか。

何が印刷されると思いますか?

6なのか11なのか?

import "fmt" 
 
func func1() (i int) { 
    i = 10 
    defer func() { 
        i += 1 
    }() 
    return 5 
} 
 
func main() { 
    closure := func1() 
    fmt.Println(closure) 
} 



3. クロージャの根本原理?

まだトップの例を使って分析中

package main 
 
import "fmt" 
 
func adder() func(int) int { 
    sum := 0 
    return func(x int) int { 
        sum += x 
        return sum 
    } 
} 
 
func main() { 
    valueFunc:= adder() 
    fmt.Println(valueFunc(2)) // output: 2 
} 



まずエスケープ解析を実行してみると、簡単にわかるのは sum として adder 関数ローカル変数の場合、スタック上ではなくヒープ上に確保されます。

これで、最初の謎が解けました。 なぜ adder 関数が返された後に sum は一緒に破壊されないのでしょうか?

$ go build -gcflags="-m -m -l" demo.go 
# command-line-arguments 
. /demo.go:8:3: adder.func1 capturing by ref: sum (addr=true assign=true width=8) 
. /demo.go:7:9: func literal escapes to heap: 
. /demo.go:7:9: flow: ~r0 = &{storage for func literal}: 
. /demo.go:7:9: from func literal (spill) at . /demo.go:7:9 
. /demo.go:7:9: from return func literal (return) at . /demo.go:7:2 
. /demo.go:6:2: sum escapes to heap: 
. /demo.go:6:2: flow: {storage for func literal} = &sum: 
. /demo.go:6:2: from func literal (captured by a closure) at . /demo.go:7:9 
. /demo.go:6:2: from sum (reference) at . /demo.go:8:3 
. /demo.go:6:2: moved to heap: sum 
. /demo.go:7:9: func literal escapes to heap 
. . /demo.go:15:23: valueFunc(2) escapes to heap: 
. /demo.go:15:23: flow: {storage for ... argument} = &{storage for valueFunc(2)}: 
. /demo.go:15:23: from valueFunc(2) (spill) at . /demo.go:15:23 
. /demo.go:15:23: flow: {heap} = {storage for ... argument}: 
. /demo.go:15:23: from ... argument (spill) at . /demo.go:15:13 
. /demo.go:15:23: from fmt.Println(valueFunc(2)) (call parameter) at . /demo.go:15:13 
. /demo.go:15:13: ... argument does not escape 
. /demo.go:15:23: valueFunc(2) escapes to heap 



しかし、もうひとつ出てくる問題は、破壊しないまでも、クロージャ関数が保存しているifが sum がコピーされると、クロージャ関数が呼び出されるたびに sum は同じであるべきで、レコードを蓄積できるのではなく、呼び出されるたびに2を返すはずである。

したがって、クロージャ関数の構造には sum へのポインタの

この疑いを確かめるために、私たちはアセンブリに行く必要がありました。

以下のコマンドを実行することで、対応するアセンブリコードを出力することができます。

go build -gcflags="-S" demo.go  



出力はかなり大きいので、クロージャ関数の構造を定義している最も重要な行を以下に抜粋した。

ここで、Fは関数へのポインタですが、それは重要ではなく、ポイントはsumが確かにポインタを格納していることで、我々の推測を検証しているのです。

type.noalg.struct { F uintptr; "".sum *int }(SB), CX 

4. パズルが明らかになる

上記3項の背景知識があれば、2項で示されたこの問題の答えがわかると思います。

まず、iは関数定義の戻り値で宣言されているので、goの言うところの caller-save パターンでは、変数 i は main 関数のスタック空間を使用します。

次に func1 return はiに5を割り当て、ここで i = 5

クロージャ関数にはこの変数iへのポインタが格納されているので

つまり、最終的にdeferでiを自己インクリメントすると、iへのポインタが直接更新され、i = 5+1となるので、最終的なプリントアウトは6となります。

import "fmt" 
 
func func1() (i int) { 
    i = 10 
    defer func() { 
        i += 1 
    }() 
    return 5 
} 
 
func main() { 
    closure := func1() 
    fmt.Println(closure) 
} 



5. 質問の再変化

上の問題が理解できたなら、次の問題を見てみましょう。

func1 変数名iを書かなくなったfunc1の戻り値、もともと具象リテラルを返していたのが、変数iになっています。この2つの小さな変化が大きな違いになりますので、結果を考えてみてください。

import "fmt" 
 
func func1() (int) { 
    i := 10 
    defer func() { 
        i += 1 
    }() 
    return i 
} 
 
func main() { 
    closure := func1() 
    fmt.Println(closure) 
} 



戻り値に変数名を書くと、その変数には main をスタック空間に書き込むのに対して、書き込まない場合は、iはあくまで func1 のスタック空間で return は元の変数 i には適用されず、関数のスタックにある別のメモリブロックに格納されます。

つまり defer には適用されません。 func1 の戻り値で

だから、印刷される結果は10にしかならない。

答えは合っていましたか?

6. 最後の問題

お気づきかどうかわかりませんが、最初の例の合計はヒープメモリに格納され、次のいくつかの例はスタックメモリに格納されています。

これはなぜでしょうか?

よく見比べてみると、例1ではクロージャ関数を返しており、加算器が返った後もクロージャ関数が別の場所で使われ続けていることがよくわかります。この場合、クロージャ関数を正しく動作させるために、クロージャ関数があるところではiをリサイクルできないので、Goコンパイラはヒープ上にインテリジェントに割り当てているのです。

一方、この後に続く他の例は、いずれもクロージャの性質に関わるだけで、クロージャ関数を直接返すわけではないので、スタック上に代入するのは完全に理にかなっています。

この記事は、Goにおけるクロージャの基本原理について書かれたものです。Goにおけるクロージャの基本原理については、Scripting Houseの過去の記事を検索するか、以下の関連記事を引き続き閲覧してください。