M.Hiroi's Home Page

Go Language Programming

お気楽 Go 言語プログラミング入門

[ PrevPage | Golang | NextPage ]

高階関数

Go 言語は手続き型のプログラミング言語ですが、Lisp / Scheme, ML, Haskell などの関数型言語のように、関数をほかのデータと同等に取り扱うことができます。つまり、関数を変数に代入したり、引数として関数に渡すことができます。関数を引数として受け取る関数を「高階関数 (higher order function)」と呼びます。また、値として関数を返すこともできるので、関数を作る関数も簡単に定義することができます。

近年、関数型言語の機能は他のプログラミング言語、たとえば Perl, Python, Ruby といったスクリプト言語にも取り入れられています。Go 言語にも関数型言語由来の機能である「匿名関数」や「クロージャ」が用意されています。今回は高階関数を中心に、匿名関数とクロージャについて説明します。なお、関数に引数を与えて呼び出すことを、関数型言語では「適用」といいます。本稿でも関数呼び出しの意味で適用を使うことにします。

●関数を引数に取る関数

簡単な例として、整数 n から m までの和を求める関数 sumOfInteger を作ってみましょう。プログラムは次のようになります。

リスト : 整数の和

func sumOfInteger(n, m int) int {
    a := 0
    for ; n <= m; n++ {
        a += n
    }
    return a
}

プログラムは簡単なので説明は不要でしょう。それでは、整数の 2 乗の和と 3 乗の和を求めるプログラムはどうなるでしょうか。次のリストを見てください。

リスト : 整数の 2 乗の和と 3 乗の和

// 2 乗
func square(x int) int {
    return x * x
}

// 3 乗
func cube(x int) int {
    return x * x * x
}

// 2 乗の和
func sumOfSquare(n, m int) int {
    a := 0
    for ; n <= m; n++ {
        a += square(n)
    }
    return a
}

// 3 乗の和
func sumOfCube(n, m int) int {
    a := 0
    for ; n <= m; n++ {
        a += cube(n)
    }
    return a
}

関数 sumOfSquare と sumOfCube の n を加算する処理で、関数 square を呼び出すと 2 乗の和を、関数 cube を呼び出すと 3 乗の和を求めることができます。sumOfSquare と sumOfCube の違いはこれだけです。ここで、square や cube を引数として渡すことができれば、sumOfSquare や sumOfCube を一つの関数で済ますことができます。次のリストを見てください。

リスト : 高階関数 sumOf

func sumOf(f func(int) int, n, m int) int {
    a := 0
    for ; n <=  m; n++ {
        a += f(n)
    }
    return a
}

関数 sumOf の引数 f に関数を渡します。関数の型宣言は次のように行います。

func(型1, 型2, ..., 型n) (型1, 型2, ..., 型n)

関数の型は関数定義とよく似ています。func の後ろのカッコに引数の型を、その後ろに返り値の型を指定します。ようするに、関数定義から関数名、仮引数名、関数本体を取り除いた形式になります。

引数に渡された関数は、今までと同じ方法で呼び出すことができます。square や cube のかわりに a += f(n) とすることで関数 f を整数 n に適用し、その返り値を累積変数 a に加算します。

簡単な実行例を示します。

リスト : 整数の和 (sample51.go)

package main

import "fmt"

//
// 関数の定義は省略
//

func main() {
    fmt.Println(sumOfInteger(1, 100))
    fmt.Println(sumOfSquare(1, 100))
    fmt.Println(sumOfCube(1, 100))
    fmt.Println(sumOf(square, 1, 100))
    fmt.Println(sumOf(cube, 1, 100))
}
$ go run sample51.go
5050
338350
25502500
338350
25502500

●匿名関数

高階関数を使うようになると、数を 2 乗する square のような小さな関数をいちいち定義するのが面倒になります。とくに、その高階関数でしか使わないのであれば、なおさらそう思うでしょう。このような場合、Go 言語では名前のない「匿名関数」を使うことができます。匿名関数の構文を示します。

func(引数1 型1, 引数2 型2, ...) (返り値の型1, 返り値の型2, ...) { 本体 }

匿名関数は関数定義から関数名を削除した形式になります。匿名関数は関数の実体 (関数値) を返すので、それを変数に格納して呼び出すことができます。また、匿名関数に実引数を渡して実行することもできますし、高階関数に匿名関数をそのまま渡すこともできます。

簡単な使用例を示します。

リスト : 匿名関数 (sample52.go)

package main

import "fmt"

func sumOf(f func(int) int, n, m int) int {
    a := 0
    for ; n <=  m; n++ {
        a += f(n)
    }
    return a
}

func main() {
    square := func(x int) int { return x * x }
    fmt.Println(square(10))
    a := func(x int) int { return x * x }(10)
    fmt.Println(a)
    fmt.Println(sumOf(square, 1, 100))
    fmt.Println(sumOf(func(x int) int { return x * x }, 1, 100))
}
$ go run sample52.go
100
100
338350
338350

main 関数の最初で、数を 2 乗する匿名関数を作り、その値を変数 square にセットします。square(10) とすれば、square に格納された関数を呼び出すことができます。匿名関数の後ろに実引数 (10) を渡せば、そのまま匿名関数を実行することができます。もちろん、square を sumOf に渡すことができますし、匿名関数をそのまま渡すこともできます。

ところで、Go 言語は関数の中で局所的な関数を定義することはできません。square のように、匿名関数を使って局所関数と同様なことはできますが、次のように := を使って再帰定義することはできません。

リスト : 再帰定義 (コンパイルエラー)

func main () {
    fact := func(x int) int {
        if x == 0 {
            return 1
        } else {
            return x * fact(x - 1)
        }
    }
    fmt.Println(fact(10))
}

匿名関数の中の fact が undefined になるのでコンパイルエラーとなります。この場合は、匿名関数を使う前に変数 fact を宣言してください。

リスト : 再帰定義 (正解)

func main () {
    var fact func(int) int
    fact = func(x int) int {
        if x == 0 {
            return 1
        } else {
            return x * fact(x - 1)
        }
    }
    fmt.Println(fact(10))
}

あらかじめ変数 fact を宣言してから、fact に匿名関数を代入します。匿名関数をコンパイルするときに変数 fact は定義済みなので、このプログラムは正しくコンパイルされます。ご注意ください。

●マッピング

次は、関数型言語でよく使われる高階関数 (マッピング、フィルター、畳み込み) を作ってみましょう。関数型言語の場合、これらの高階関数は連結リスト (linked list) の要素に引数の関数を適用するものです。ここではリストのかわりにスライスを使うことにします。

ただし、スライスの型を [ ]int に限定します。動的な型付けを行う Lisp / Scheme や、多相型関数を定義できる ML (SML/NJ, OCaml) や Haskell では、リストの型を限定する必要はないのですが、Go 言語は静的に強く型付けされた言語なので、スライスや関数の型を決めないとコンパイルできません。Go 言語の場合、空インターフェース (interface{}) を使うといろいろなデータに対応できるようになります。これは次回以降に説明したいと思います。

まず最初に、スライスの要素に引数の関数 f を適用し、その結果をスライスに格納して返す関数を作ります。このような操作を「マッピング(写像)」といいます。次のリストを見てください。

リスト : マッピング

func mapcar(f func(int) int, ary []int) []int {
    buff := make([]int, len(ary))
    for i, v := range ary {
        buff[i] = f(v)
    }
    return buff
}

関数名は mapcar としました。名前は Common Lisp から拝借しました。プログラムは簡単です。for 文でスライス ary の要素を順番に取り出し、それを関数 f に渡して評価します。その結果をスライス buff に追加し、最後に buff を返します。

●フィルター

フィルター (filter) はスライスの要素に関数を適用し、関数が真を返す要素をスライスに格納して返す関数です。ここでは簡単な例題として、関数が真を返す要素を削除する関数 removeIf を作ってみましょう。関数名は Common Lisp から拝借しました。

リスト : 要素の削除

func removeIf(f func(int) bool, ary []int) []int {
    buff := make([]int, 0)
    for _, v := range ary {
        if !f(v) {
            buff = append(buff, v)
        }
    }
    return buff
}

mapcar と同様に removeIf も簡単です。最初に空のスライスを変数 buff にセットします。そして関数 f が偽を返したならば v を append でスライス buff に追加するだけです。

もちろん、フィルターも簡単に定義することができます。removeIf とは逆に、関数 f が真を返すとき要素をスライスに追加し、偽を返すときはスライスに加えません。

リスト : フィルター

func filter(f func(int) bool, ary []int) []int {
    buff := make([]int, 0)
    for _, v := range ary {
        if f(v) {
            buff = append(buff, v)
        }
    }
    return buff
}

●畳み込み

2 つの引数を取る関数 f とスライスを引数に受け取る関数 reduce を考えます。reduce はスライスの各要素に対して関数 f を下図のように適用します。

(1) [a1, a2, a3, a4, a5]
    => f( f( f( f( a1, a2 ), a3 ), a4 ), a5 )

(2) [a1, a2, a3, a4, a5]
    => f( a1, f( a2, f( a3, f( a4, a5 ) ) ) )


        図 : reduce の動作

関数 f を適用する順番で 2 通りの方法があります。図 (1) はスライスの先頭から f を適用し、図 (2) はスライスの後ろから f を適用します。たとえば、関数 f が単純な加算関数とすると、reduce の結果はどちらの場合もスライスの要素の和になります。

f(x, y) = x + y の場合
reduce => a1 + a2 + a3 + a4 + a5

このように、reduce はスライスのすべての要素を関数 f を用いて結合します。このような操作を「縮約」とか「畳み込み」といいます。また、reduce の引数に初期値 g を指定することがあります。この場合、reduce は下図に示す動作になります。

(1) [a1, a2, a3, a4, a5]
    => f( f( f( f( f( g, a1 ), a2 ), a3 ), a4 ), a5 )

(2) [a1, a2, a3, a4, a5]
    => f( a1, f( a2, f( a3, f( a4, f( a5, g ) ) ) ) )


        図 : reduce() の動作 (2)

ここでは簡単な例題として、上図 (1) の動作を行う関数 foldl と、上図 (2) の動作を行う関数 foldr を作ってみましょう。次のリストを見てください。

リスト : 畳み込み

func foldl(f func(int, int) int, a int, ary []int) int {
    for _, x := range ary {
        a = f(a, x)
    }
    return a
}

func foldr(f func(int, int) int, a int, ary []int) int {
    for i := len(ary) - 1; i >= 0; i-- {
        a = f(ary[i], a)
    }
    return a
}

foldl の引数 ary がスライス、a が初期値です。for ループで ary の要素を一つずつ取り出して関数 f に渡して実行します。その結果を変数 a にセットします。foldl は変数 a の値を関数 f の返り値で更新することで、図 (1) の動作を実現しています。

たとえば、スライスが [1, 2, 3] で a が 0 とします。最初は f(0, 1) が実行され、その返り値が a にセットされます。次は f(a, 2) が実行されますが、これは f(f(0, 1), 2) と同じことです。そして、その結果が a にセットされます。最後に f(a, 3) が実行されますが、これは f(f(f(0, 1), 2), 3) となり、図 (1) と同じ動作になります。

foldl の場合、スライスの要素が関数の第 2 引数になり、第 1 引数にはこれまでの処理結果が渡されます。これに対し、foldr の場合は逆になり、ブロックの第 1 引数にスライスの要素が渡されて、これまでの処理結果は第 2 引数に渡されます。

foldl, foldr は関数型言語でよく用いられる高階関数で、2 引数の関数と組み合わせると、いろいろな処理を簡単に実現することができます。

●実行例

それでは、簡単な実行例を示します。

リスト : 高階関数の使用例 (sample53.go)

package main

import "fmt"

//
// 今までの関数定義は省略
//

func isEven(x int) bool {
    return x % 2 == 0
}

func isOdd(x int) bool {
    return x % 2 != 0
}

func add(x, y int) int {
    return x + y
}

func main() {
    a := []int{1,2,3,4,5,6,7,8}
    b := mapcar(square, a)
    c := mapcar(cube, a)
    fmt.Println(b)
    fmt.Println(c)
    d := removeIf(isEven, a)
    e := removeIf(isOdd, a)
    fmt.Println(d)
    fmt.Println(e)
    fmt.Println(foldl(add, 0, a))
    fmt.Println(foldr(add, 0, a))
}
$ go run sample53.go
[1 4 9 16 25 36 49 64]
[1 8 27 64 125 216 343 512]
[1 3 5 7]
[2 4 6 8]
36
36

●レキシカルスコープ

ここで、もう少し詳しく局所変数の規則を考えてみましょう。変数 x を表示する関数 foo を定義します。

リスト : レキシカルスコープ (sample54.go)

package main

import "fmt"

var x int = 10

func foo() {
    fmt.Println(x)
}

func foo1() {
    x := 100
    fmt.Println("local var x =", x)
    foo()
}

func main() {
    foo()
    foo1()
}

foo には変数 x を定義していないので、foo を実行した場合は大域変数の値を探しにいきます。それでは foo1 という関数から foo を呼び出す場合を考えてみましょう。foo1 には局所変数 x を定義します。この場合、foo はどちらの値を表示するのでしょうか。実際に試してみましょう。

$ go run sample54.go
10
local var x = 100
10

大域変数の値を表示しました。このように、foo1 で定義した局所変数 x は、foo からアクセスすることはできません。次の図を見てください。

図では、変数の有効範囲を枠で表しています。foo1 で定義した局所変数 x は、関数 foo1 の枠の中でのみ有効です。もしも、この枠で変数が見つからない場合は、ひとつ外側の枠を調べます。この場合、関数定義の枠しかないので、ここで変数が見つからない場合は大域変数を調べます。

foo は関数定義の枠しかありません。そこに変数 x が定義されていないので、大域変数を調べることになるのです。このように、foo から foo1 の枠を超えて変数 x にアクセスすることはできないのです。これを「レキシカルスコープ (lexical scope)」といいます。レキシカルには文脈上いう意味があり、変数が定義されている範囲内 (枠内) でないと、その変数にアクセスすることはできません。

●匿名関数と局所変数

それでは、匿名関数の場合はどうでしょうか。次のリストを見てください。

リスト : スライスの要素を n 倍する

func timesElement(n int, ary []int) []int {
    return mapcar(func(x int) int { return x * n }, ary)
}

匿名関数の仮引数は x だけですから、変数 n は大域変数をアクセスすると思われるかもしれません。ところが、変数 n は関数 timesElement の引数 n をアクセスするのです。次の図を見てください。

ポイントは、匿名関数が timesElement 内で定義されているところです。変数 n は関数の引数として定義されていて、その有効範囲は関数の終わりまでです。匿名関数はその範囲内に定義されているため、変数 n にアクセスすることができるのです。つまり、関数内で定義された匿名関数は、そのとき有効な局所変数にアクセスすることができます。

●クロージャ

次は、関数型言語でよく用いられるテクニックを紹介しましょう。Lisp などの関数型言語では、関数を生成する関数を簡単に作ることができます。このとき使われる機能が「クロージャ (closure)」です。クロージャは評価する関数と参照可能な局所変数をまとめたものです。クロージャは関数のように実行することができますが、クロージャを生成するときに参照可能な局所変数を保存するところが異なります。参照可能な局所変数の集合を「環境」と呼ぶことがあります。

Go 言語でクロージャを生成するには匿名関数を使います。たとえば、「引数を n 倍する関数」を生成する関数は、次のようになります。

リスト : クロージャの使用例 (sample55.go)

package main

import "fmt"

func foo(n int) func(int) int {
    return func(x int) int { return x * n }
}

func main() {
    foo10 := foo(10)
    foo20 := foo(20)
    fmt.Println(foo10(1))
    fmt.Println(foo20(10))
}
$ go run sample55.go
10
200

関数 foo は引数を n 倍する関数を生成します。関数を返すので、返り値の型は生成する関数の型 func(int) int になります。変数 foo10 に foo(10) の返り値をセットします。すると、foo10 は引数を 10 倍する関数として使うことができます。同様に、変数 foo20 に foo(20) の返り値をセットすると、foo20 は引数を 20 倍する関数になります。

匿名関数で関数を生成するとき、評価する関数のほかに、そのとき参照可能な局所変数、つまり「環境」もいっしょに保存されます。この場合、参照可能な局所変数は foo の引数 n です。そして、クロージャを実行するときは、保存されている局所変数にアクセスすることができるのです。

foo(10) を実行して匿名関数を生成するとき、定義されている局所変数は n で、その値は 10 です。この値がクロージャに保存されているので、foo10 の関数は引数を 10 倍した結果を返します。foo(20) を評価すると n の値は 20 で、それがクロージャに保存されているので、foo20 の関数は引数を 20 倍した結果を返すのです。

もうひとつ面白い例題を紹介しましょう。次のリストを見てください。

リスト : カリー化関数 (sample56.go)

package main

import "fmt"

func mapcar(f func (x int) int) func([]int) []int {
    return func(ary []int) []int {
        buff := make([]int, len(ary))
        for i, v := range ary {
            buff[i] = f(v)
        }
        return buff
    }
}

func square(x int) int {
    return x * x
}

func cube(x int) int {
    return x * x * x
}

func main() {
    a := []int{1,2,3,4,5,6,7,8}
    squareAry := mapcar(square)
    cubeAry := mapcar(cube)
    fmt.Println(squareAry(a))
    fmt.Println(cubeAry(a))
    fmt.Println(mapcar(square)(a))
    fmt.Println(mapcar(cube)(a))
}
$ go run sample56.go
[1 4 9 16 25 36 49 64]
[1 8 27 64 125 216 343 512]
[1 4 9 16 25 36 49 64]
[1 8 27 64 125 216 343 512]

このプログラムは関数 mapcar を 1 引数の関数に直したものです。mapcar は関数 f を受け取り、その f を呼び出してスライスを操作する関数を返します。これでもマッピングの動作ができるのです。

1, 2 番目の例は mapcar で生成した関数を変数 squareAry と cubeAty にセットし、それから squareAry と cubAry を関数呼び出しします。次の例は、mapcar の返り値を直接関数呼び出ししています。カッコが多くなりますが、2 引数の mapcar と同じように呼び出すことができます。これでもスライスの要素を 2 乗、3 乗することができます。

3, 4 番目の例は、最初の引数を受け取って新しい関数を生成して返し、その関数に次の引数を適用して値を求めるという動作になります。このように、関数の引数が一つでも、「関数を返す関数」を使うことで、複数の引数を処理することができます。このような関数を「カリー化関数 (curried function)」といいます。

関数型言語には、カリー化関数をサポートしているプログラミング言語、たとえば Haskell や ML (SML/NJ, Ocaml) などがあります。これらのプログラミング言語では、高階関数はカリー化関数として定義されています。また、関数を合成して新しい関数を作ることも簡単にできます。

●ジェネレータ

最後に、クロージャの応用例として「ジェネレータ (generator)」というプログラムを紹介しましょう。ジェネレータは、呼び出されるたびに新しい値を生成していきます。

簡単な例題として、奇数列 (1, 3, 5, ...) を発生するジェネレータを作ってみます。関数名は genOddNumber としましょう。genOddNumber は呼び出されるたびに新しい奇数を返します。いちばん簡単な実装方法は、返した値を大域変数に記憶しておくことです。genOddNumber のプログラムは次のようになります。

リスト : 奇数を発生するジェネレータ (sample57.go)

package main

import "fmt"

var prevNumber = -1

func genOddNumber() int {
    prevNumber += 2
    return prevNumber
}

func main() {
    for i := 0; i < 8; i++ {
        fmt.Println(genOddNumber())
    }
}
$ go run sample57.go
1
3
5
7
9
11
13
15

大域変数 prevNumber は、genOddNumber が返した値を記憶します。新しい値は、この prevNumber に 2 を足せばいいのです。

このように、大域変数を使うと簡単にジェネレータを作ることができますが問題点もあります。それは、複数のジェネレータが必要になる場合です。単純に考えると、必要な数だけ大域変数と関数を用意すればいいのですが、数が増えると大域変数や関数を定義するだけでも大変な作業になります。

ところがクロージャを使うと、もっとスマートにジェネレータを用意できます。まず、ジェネレータを作る関数を定義します。

リスト : ジェネレータを作る関数 (sample58.go)

package main

import "fmt"

func makeGen() func() int {
    prevNumber := -1
    return func() int {
        prevNumber += 2
        return prevNumber
    }
}

func main() {
    g1 := makeGen()
    for i := 0; i < 8; i++ {
        fmt.Println(g1())
    }
    g2 := makeGen()
    for i := 0; i < 8; i++ {
        fmt.Println(g2())
    }
}

関数 makeGen はクロージャを返します。そして、このクロージャがジェネレータの役割を果たすのです。それでは、実際に実行してみましょう。

$ go run sample58.go
1
3
5
7
9
11
13
15
1
3
5
7
9
11
13
15

makeGen で作成したクロージャを変数 g1 にセットして実行します。実行するたびに 1, 3, 5 と奇数列を生成していますね。次に新しいクロージャを変数 g2 にセットします。このクロージャを実行すると、新しい奇数列を生成します。確かにジェネレータとして動作しています。

このプログラムのポイントは局所変数 prevNumber です。クロージャで保存される環境は変数 prevNumber です。この値は makeGen が実行されたときに -1 で初期化されています。クロージャにはこの値が保存されます。

次に、g1 にセットしたクロージャを実行します。匿名関数は、クロージャに保存された局所変数にアクセスするので、prevNumber += 2 の値は 1 になり、クロージャに保持されている prevNumber の値は 1 に更新されます。

環境はクロージャによって異なります。g1 のクロージャが評価されると、そのクロージャの環境が更新されるのであって、ほかのクロージャに影響を与えることはありません。したがって、ジェネレータが発生する奇数列が、ほかのジェネレータに影響を与えることはないのです。あとは必要な数だけジェネレータを makeGen で作り、そのクロージャを変数に格納しておけばいいわけです。

次は、奇数列を最初に戻す、つまり、ジェネレータをリセットすることを考えてみましょう。この場合、クロージャ内の変数を書き換えるしか方法はありません。そこで、makeGen の返り値を 2 つに増やすことにします。最初の返り値は奇数列を発生するジェネレータで、2 番目の返り値はジェネレータをリセットする関数とします。プログラムは次のようになります。

リスト : ジェネレータのリセット (sample59.go)

package main

import "fmt"

func makeGen() (func() int, func()) {
    prevNumber := -1
    gen := func() int {
        prevNumber += 2
        return prevNumber
    }
    reset := func() { prevNumber = -1 }
    return gen, reset
}

func main() {
    g1, r1 := makeGen()
    for i := 0; i < 8; i++ {
        fmt.Println(g1())
    }
    r1()
    for i := 0; i < 8; i++ {
        fmt.Println(g1())
    }
}

makeGen の変数 gen に奇数列を生成する関数をセットし、変数 reset に prevNumber を -1 に書き換える関数をセットします。あとは、return で gen と reset を返すだけです。makeGen を呼び出す側は多値を多重代入で受け取ります。g1 を呼び出すと奇数列を生成し、r1 を呼び出すとジェネレータはリセットされます。

それでは実行してみましょう。

$ go run sample59.go
1
3
5
7
9
11
13
15
1
3
5
7
9
11
13
15

正常に動作していますね。

クロージャは少し難しいかもしれませんが、 便利で面白い機能です。少々歯応えがありますが、 これもプログラミングの面白いところだと思います。Go 言語は手続き型言語なのでクロージャを使う機会は少ないと思いますが、興味のある方はいろいろと試してみてください。 また、関数を扱うことは、やっぱり関数型言語の方が優れています。クロージャの話に興味をもたれた方は、ぜひ関数型言語 (Lisp, ML, Haskell など) にも挑戦してみてください。

今回はここまでです。次回はC言語で有名な「ポインタ」について説明します。


初版 2014 年 2 月 16 日
改訂 2021 年 12 月 11 日

Copyright (C) 2014-2021 Makoto Hiroi
All rights reserved.

[ PrevPage | Golang | NextPage ]