Go言語で学ぶクロージャー

JavaScriptを有効にしてください

前書き

Tour of Go で解説されている、Goの関数がクロージャということについて分かりにくかったので、先生(ChatGPT)に聞いて噛み砕いて解説してみました。
また先生への質問も載せていますので合わせてみるとよりよく理解できるかと思います。
「クロージャー=外部変数をキャプチャした関数」という理解になれば完璧です。

そもそもクロージャとは?

クロージャというのは、「関数が作られた時の周りの環境(変数など)を覚えていて、後からその情報を使える」という意味です。

もっと説明すると、まず関数は「情報を保持できる箱」のようなものです。

  • 関数を作ると…
    関数内には、特定の変数や値が保存されます。

  • 作られた場所の情報を覚えている
    関数は、定義されたときの環境(利用可能な変数など)を記憶しています。

  • 後からその情報を利用できる
    どこで関数を実行しても、定義時に保存された情報を参照・更新することが可能です。

この仕組みにより、関数は定義された環境を保持しながら、後の実行時にその情報を活用することができます。

例で説明

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

func main() {
    // countという変数を作る
    count := 0

    // 関数makeIncrementerを作る
    increment := func() int {
        count++         // この関数はcountという外側の変数を使っています
        return count
    }

    // incrementはクロージャなので、main内のcountを覚えていて、使い続けられる
    fmt.Println(increment())  // 出力: 1
    fmt.Println(increment())  // 出力: 2
}

この例では、increment という関数がcountという変数を使っています。関数incrementは作られたときにcountの状態を覚えていて、呼び出すたびにその値を更新しています。これがクロージャの働きです。

上記の例では、まえがきで説明した「クロージャ=外部変数をキャプチャした関数」という概念が、具体的に count という変数がキャプチャされていることで実現されていることが分かります。

Tour of Goのサンプルコードを読み解く

ここでは、先ほどの知識をもとに、Tour of Go のサンプルコードを詳しく見ていきます。このコードは、クロージャ を活用して、関数内で状態(変数の値)を保持する仕組みを実現しています。順を追って解説しましょう。

1. adder 関数

go
1
2
3
4
5
6
7
func adder() func(int) int {
	sum := 0
	return func(x int) int {
		sum += x
		return sum
	}
}

初学者の方は、「func が連続して出てきて、どうなっているのか分からない…」と思われるかもしれません。そこで、このコード内の各 func の役割について、詳しく解説していきます。

一つ目の funcadder 関数

  • 宣言部分:

    go
    1
    
    func adder() func(int) int {
    
    • 役割:
      adder は「関数を返す関数」です。
      戻り値の型が func(int) int となっており、整数を受け取って「整数を返す関数」を返すことがわかります。
  • 処理内容:

    • ローカル変数 sum0 に初期化します。
    • 内側で定義された無名関数(二つ目の func)を返します。
  • まとめ:
    一つ目の func は、外側の関数 adder で、クロージャ(内部で作られる無名関数)を生成して返す役割を持っています。

二つ目の func:無名関数

  • 宣言部分:

    go
    1
    2
    3
    4
    
    return func(x int) int {
        sum += x
        return sum
    }
    
    • 役割:
      この無名関数は、引数 x を受け取ると、外側の adder 関数で宣言された sumx を加え、その結果を返します。
  • 特徴:

    • クロージャとしての性質:
      この関数は作成されたときの環境、つまり sum という変数の状態を「覚えている」ため、adder が終了しても sum の値を保持し、呼び出すたびに更新できます。
  • まとめ:
    二つ目の func は、実際に数値を加算する処理を行う関数であり、外側の adder が用意した sum を操作するクロージャです。

全体の流れ

  1. adder 関数が呼ばれると、

    • ローカル変数 sum0 に初期化されます。
    • 内側の無名関数(クロージャ)が返され、そのクロージャは sum の値を覚えています。
  2. 返された無名関数を呼び出すと、

    • 引数として渡された xsum に加えられ、更新された sum の値が返されます。

このように、一つ目の funcadder)は「クロージャを生成して返す関数」であり、二つ目の func(無名関数)は「実際に数値の加算処理を行い、外側の sum 変数を操作する関数」として機能しています。

2. main 関数での利用

go
1
2
3
4
5
6
7
8
9
func main() {
	pos, neg := adder(), adder()
	for i := 0; i < 10; i++ {
		fmt.Println(
			pos(i),
			neg(-2*i),
		)
	}
}
  • posneg の2つのクロージャ

    • pos := adder() で作られたクロージャは、自分の中でsumを持っており、posを呼び出すとそのsumに引数を足して返します。
    • neg := adder() で作られたクロージャも、別の独立したsumを持っています。
      それぞれ独立しているので、一方のsumに変更を加えてももう一方には影響しません。
  • ループ内の処理
    ループはi = 0からi = 9まで回ります。

    • pos(i) は、現在のiの値をsumに足して、その合計を返します。つまり、sumは徐々に大きくなっていきます。
    • neg(-2*i) は、-2*isumに足して、その結果を返します。こちらは毎回負の値が加わるため、sumは負の方向に進みます。

たとえば、ループの最初の数回を見てみましょう:

  • i = 0 のとき

    • pos(0)sum0 を足すので、結果は 0
    • neg(0)sum0 を足すので、結果は 0
  • i = 1 のとき

    • pos(1) → 前回のsum0、ここに1を足して1
    • neg(-2) → 前回のsum0、ここに-2を足して-2
  • i = 2 のとき

    • pos(2) → 前回のsum1、ここに2を足して3
    • neg(-4) → 前回のsum-2、ここに-4を足して-6

このように、posnegはそれぞれ独自に状態(sumの値)を保持しながら変化していくのです。

クロージャについてさらに掘り下げる

ここまでの説明で、まだピンとこない部分もあるかもしれません。私自身も混乱した経験があったので、先生(ChatGPT)に質問した内容とその回答を以下にまとめました。

Q. 説明を聞いていると、無名関数=クロージャという印象を受けるのですが…

無名関数とクロージャは似た概念ですが、厳密には同じものではありません。

  • 無名関数:
    名前のない関数のことです。たとえば、変数に代入したり、直接返り値として利用する関数は無名関数です。
    例:

    go
    1
    2
    3
    
    func(x int) int {
        return x * 2
    }
    
  • クロージャ:
    関数と、その関数が定義されたときの環境(外側の変数など)が一体となったものです。
    クロージャは、無名関数でも名前付き関数でも実現可能で、関数が外側の変数にアクセスできる状態になっているものを指します。

つまり:

  • 無名関数が必ずしもクロージャであるわけではありません。
    無名関数が外側の変数をキャプチャしている場合はクロージャと呼ばれますが、キャプチャしていなければ単なる無名関数です。

  • 名前付き関数もクロージャになり得ます。
    名前付き関数が外側の変数にアクセスできれば、それもクロージャです。

まとめると、上記のコードに登場する無名関数は、外側の sum という変数をキャプチャしているため、クロージャと呼べます。ただし、無名関数すべてがクロージャであるわけではなく、外側の環境をキャプチャしているかどうかが重要です。

Q. 「ただの関数」が外部環境をキャプチャすると「クロージャ」に変身するということですね

その印象は正しいです。基本的には、ただの関数は外部の環境(変数など)をキャプチャしていない、単なる手続きの塊です。しかし、外部の環境をキャプチャするようになると、その関数はクロージャとなります。

つまり:

  • 関数そのもの:
    外部変数にアクセスしていない場合は、単なる関数です。クロージャとは呼ばれません。

  • クロージャ:
    関数が自分の定義された環境(外部の変数など)をキャプチャして保持する状態になったものです。キャプチャした環境があることで、その関数は後からでもその環境にアクセス・更新することが可能になります。

このように、同じ関数でも、外部の変数を使っていなければただの関数、使っていればクロージャと呼ばれるという、まるで変身するかのような性質を持っています。

Q. キャプチャ可能な外部変数にはどのようなものがありますか?

Goのクロージャは主に、そのレキシカルなスコープに存在する変数(ローカル変数、関数の引数、レシーバーなど)をキャプチャします。つまり、「外部変数」と呼ぶ場合、その関数が定義された環境に存在する変数全般が対象となります。

具体的には:

  • ローカル変数: 関数内で定義された変数。
  • 関数の引数: 関数に渡された値も、そのスコープ内にあるためキャプチャされる可能性があります。
  • レシーバー: メソッドの場合、レシーバー(メソッドを呼び出すオブジェクト)の参照もキャプチャされます。

これらはすべて「変数」ですが、外部変数というと、外側のスコープに定義されている変数を指す場合が多いです。その他、グローバル変数や定数も参照できますが、これらはすでにプログラム全体でアクセス可能なため、キャプチャの意味合いは少し異なります。

まとめると、クロージャがキャプチャするのは基本的に関数が定義されたスコープ内にある値であり、それは「外部変数」だけでなく、引数やレシーバーなども含みます。ただし、すべては結局「変数」や「値」として存在するため、外部変数以外にキャプチャできるものというと、結局はスコープ内にある全ての値、ということになります。

Q. 3番目のレシーバーのコード例がイメージできません

レシーバーがキャプチャされる例として、以下のコードをご覧ください。ここでは、メソッドのレシーバー(構造体のインスタンス)がクロージャ内で使われ、キャプチャされる例を示しています。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import "fmt"

type Counter struct {
	count int
}

// Incrementer メソッドは、Counter のレシーバーをキャプチャしたクロージャを返します。
func (c *Counter) Incrementer() func(int) int {
	return func(x int) int {
		c.count += x
		return c.count
	}
}

func main() {
	c := &Counter{}         // Counter のインスタンスを作成
	increment := c.Incrementer() // クロージャを取得(ここでレシーバー c がキャプチャされる)

	fmt.Println(increment(1)) // 出力: 1  (c.count が 1 になる)
	fmt.Println(increment(2)) // 出力: 3  (c.count が 3 になる)
	fmt.Println(increment(5)) // 出力: 8  (c.count が 8 になる)
}

解説

  • Counter 型は、count というフィールドを持っています。
  • Incrementer メソッドは、*Counter 型のレシーバー c を受け取り、無名関数を返します。
  • この無名関数は、c.count にアクセスしているため、c(レシーバー)がクロージャ内でキャプチャされます。
  • main 関数で作成した Counter のインスタンス c の状態は、increment クロージャを通じて更新・参照されます。

このように、メソッドのレシーバーもクロージャ内でキャプチャされるため、外部変数として扱われ、後からその状態にアクセス・変更できるのです。

Q.クロージャという言葉の定義はどこからきているのですか?

「クロージャ (closure)」という用語は、主に数学の概念プログラミング言語理論から来ています。

1. 数学や論理学の背景

  • 閉包(closure)の概念
    数学では「閉包」とは、ある集合が特定の操作(例えば、和や積など)を施したときに、その結果も同じ集合に含まれる性質のことを言います。
    この考え方が、プログラミングにおいて「関数が外部の変数を取り込んで、その変数とともに動作する」という概念に応用されました。

2. プログラミング言語理論とラムダ計算

  • ラムダ計算 (lambda calculus)
    ラムダ計算は、関数やその適用を形式的に扱うための計算体系で、Alonzo Churchによって考案されました。
    この体系の中で、関数とその関数が参照する環境(変数の束縛)の関係が扱われ、「関数が作られた環境を持つ」という考えが出てきました。

  • 「閉じる (to close)」という表現
    関数がその定義時の環境(外部の変数など)を「閉じ込める」=「閉じる(close)」という表現から、「クロージャ」という用語が生まれました。
    つまり、関数が自分の定義された環境を取り込み(キャプチャし)、それを後からでも利用できる状態になっていることを指します。

3. プログラミング言語での普及

  • Lisp、Scheme、MLなどの関数型プログラミング言語が、クロージャの概念を積極的に取り入れ、広めたことで、「クロージャ」という言葉は定着しました。

つまり…

クロージャという言葉は、数学の「閉包」の概念や、ラムダ計算の中での関数と環境の関係に由来しており、「関数が自分の作られた環境を『閉じ込める』」という意味合いから名付けられました。

Q. クロージャーという概念は他の言語でも同じ認識でいいですか?

基本的な概念としては、どの言語でも「クロージャ」は、関数が定義されたときの環境(変数やスコープなど)を保持し、後からでもその環境にアクセスできる機能を指します。

しかし、言語ごとに実装の詳細や動作(たとえば、変数のライフタイム、メモリ管理、最適化など)が異なることがあるので、厳密な動作については各言語の仕様を確認する必要があります。

クロージャは、特定の状態を関数内部に閉じ込め、その状態に対する操作を一つの関数で行える点が強みです。以下に、クロージャを使う場合と使わない場合のコード例を示します。

Q.クロージャーを使う場合と使わない場合でメリットが分かるコード例が欲しいです

クロージャは、特定の状態を関数内部に閉じ込め、その状態に対する操作を一つの関数で行える点が強みです。以下に、クロージャを使う場合と使わない場合のコード例を示します。

クロージャを使う例

たとえば、カウンターを作る関数を考えてみます。クロージャを使うと、カウンターの状態を関数内部に隠蔽でき、呼び出すたびにその状態を更新できます。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package main

import "fmt"

// makeCounter は、内部状態を持つクロージャ(関数)を返します。
func makeCounter() func() int {
	count := 0 // この変数はクロージャ内に閉じ込められる
	return func() int {
		count++
		return count
	}
}

func main() {
	counter := makeCounter()
	fmt.Println("クロージャ使用:")
	fmt.Println(counter()) // 出力: 1
	fmt.Println(counter()) // 出力: 2
	fmt.Println(counter()) // 出力: 3
}

メリット:

  • 状態(count)が関数内部に隠蔽され、外部から直接変更できないため、安全性が高い。
  • 必要なときに簡潔にインスタンス化でき、複数の独立したカウンターを作成できる。

クロージャを使わない例

クロージャを使わずに同じ機能を実現する場合、通常は状態を保持するために構造体やグローバル変数を利用する必要があります。

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "fmt"

// Counter はカウンターの状態を保持する構造体です。
type Counter struct {
	count int
}

// Next はカウンターをインクリメントして返すメソッドです。
func (c *Counter) Next() int {
	c.count++
	return c.count
}

func main() {
	var counter Counter // 構造体のインスタンス
	fmt.Println("クロージャ未使用:")
	fmt.Println(counter.Next()) // 出力: 1
	fmt.Println(counter.Next()) // 出力: 2
	fmt.Println(counter.Next()) // 出力: 3
}

デメリット(クロージャを使わない場合):

  • 構造体を定義し、その状態を操作するメソッドを用意するため、コード量が増える。
  • 状態管理のための型定義や初期化が必要となり、より複雑な設計になる可能性がある。

クロージャーを使用する場合と使用しない場合のまとめ

  • クロージャ使用:
    状態を関数内部に隠蔽し、必要なときに簡単に複数のインスタンスを生成できる。
    メリット: コードがシンプルで、カプセル化が自然に実現できる。

  • クロージャ未使用:
    構造体やグローバル変数を用いて状態管理を行う。
    デメリット: コード量が増え、状態管理のための設計が必要になる。

このように、クロージャを使うことで、必要な状態をコンパクトに管理できるため、特定の場面では大きなメリットを得られます。

まとめ

ここまでのやり取りで、クロージャの仕組みや概念、そしてその応用例について深く掘り下げてきました。
以下に、主要なポイントを改めてまとめます。

  • クロージャの特徴
    関数が定義されたときの環境(たとえば、adder 関数内の sum など)をキャプチャするため、後からその環境の情報にアクセスでき、状態を更新することが可能です。

  • コード例でのポイント

    • adder 関数は、内部に sum という変数を持ち、無名関数(クロージャ)を返します。
    • この無名関数は、sum をキャプチャしているため、複数回呼び出してもその状態を保持・更新できます。
    • 複数のクロージャ(たとえば、posneg)はそれぞれ独立した環境をキャプチャするので、お互いの状態に干渉せずに動作します。
  • 補足: 名前付き関数とレシーバー

    • 無名関数だけでなく、名前付き関数も外側の変数やレシーバーをキャプチャすればクロージャとなります。
    • たとえば、メソッド内で定義された関数がレシーバー(オブジェクトの状態)にアクセスする場合、そのレシーバーもキャプチャされ、後から状態を変更・利用できます。

このように、Goのクロージャは、関数が作成されたときの状態を「閉じ込め」ることで、後からの呼び出しでその状態を自在に扱うことができる強力な仕組みです。

改めて Tour of Go に戻って読むと、「キャプチャ」ではなく「バインド」という表現が使われていますが、どちらも同じ意味になります。

解説は以上になります。この解説がクロージャーの理解の一助になれば幸いです。


スポンサーリンク

共有

もふもふ
著者
もふもふ
プログラマ。汎用系→ゲームエンジニア→Webエンジニア→QAエンジニア