M.Hiroi's Home Page

お気楽 Scala プログラミング入門

レキシカルスコープとクロージャ


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

はじめに

変数の有効範囲を表す用語に「スコープ (scope)」があります。この用語を使うと、厳密ではありませんが、変数の有効範囲は「レキシカルスコープ (lexical scope)」と「ダイナミックスコープ (dynamic scope)」の 2 つに分けることができます。伝統的な Lisp、たとえば Emacs Lisp はダイナミックスコープですが、現在の Scheme や Common Lisp はレキシカルスコープです。Scala もレキシカルスコープを採用しています。

●レキシカルスコープ

それでは、レキシカルスコープについて詳しく見てみましょう。フィールド変数 x を表示する関数 foo を定義します。

リスト ; レキシカルスコープ

object sample0503 {
  var x = 10

  def foo() = println(x)

  def foo1() = {
    val x = 100
    println("local value: " + x)
    foo()
  }

  def main(args: Array[String]): Unit = {
    println("call foo")
    foo()
    println("call foo1")
    foo1()
  }
}
$ scalac sample0503.scala
$ sample0503
call foo
10
call foo1
local value: 100
10

関数 foo には局所変数 x を定義していないので、foo を実行した場合はフィールド変数の値を参照します。その結果 10 が表示されます。それでは、foo1 という関数から foo を呼び出す場合を考えてみましょう。foo1 には let で局所変数 x を定義します。この場合、foo はどちらの値を表示するのでしょうか。実際に試してみると、10 と表示されました。

このように、foo1 で定義した局所変数 x は、foo から参照することはできません。次の図を見てください。

┌────── object sample0503─────┐ 
│                                        │
│        フィールド変数  x ←────┐  │
│                                    │  │
│  ┌→┌─ 関数 foo ──────┐  │  │
│  │  │                ┌───┼─┘  │
│  │  │        println(x)      │      │
│  │  │                        │      │
│  │  └────────────┘      │
│  │  ┌─ 関数 foo1  ─────┐      │
│  │  │                        │      │
│  │  │  ┌─val : x ───┐  │      │
│  │  │  │                │  │      │
│  └─┼─┼─ foo()        │  │      │
│      │  └────────┘  │      │
│      └────────────┘      │
│                                        │
└────────────────────┘

        図 : レキシカルスコープ

上図では変数の有効範囲を枠で表しています。foo1 の val で定義した局所変数 x は、それを定義したブロックの枠の中でのみ有効です。もしも、この枠で変数が見つからない場合は、ひとつ外側の枠を調べます。順番に外側の枠を調べていくと、最後には関数定義の枠に行き着きます。ここで変数(引数)が見つからない場合はフィールド変数を調べます。

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

ところが伝統的な Lisp の場合、foo1 で定義した変数 x は呼び出された関数 foo からアクセスすることができます。これを「ダイナミックスコープ」といいます。foo1 で定義された変数 x は、foo1 の実行が終了するまで存在します。そして、foo1 から呼ばれた関数ならば、どこからでも参照することができるのです。もしも、foo1 をダイナミックスコープの処理系、たとえば Emacs Lisp で実行するならば、foo で表示される x の値は 100 になります。

●レキシカルスコープと局所関数

それでは、関数の中で定義された局所関数や無名関数の場合はどうなるのでしょうか。次の例を見てください。

scala> def timesElement(n: Int, xs: List[Int]): List[Int] =
     | xs.map((x: Int)=> x * n)
def timesElement(n: Int, xs: List[Int]): List[Int]

scala> timesElement(10, List(1,2,3,4,5))
val res0: List[Int] = List(10, 20, 30, 40, 50)

無名関数の引数は x だけなので、変数 n はフィールド変数を参照するように思われるかもしれません。ところが、変数 n は関数 timesElement の引数 n を参照するのです。これを図に示すと、次のようになります。

┌──────  Scala REPL  ──────┐
│                                      │
│    ┌─ timesElement : n, xs ─┐    │
│    │                  ↑      │    │
│    │                  └─┐  │    │
│    │  ┌── fun : x  ─┐│  │    │
│    │  │          ↑    ││  │    │
│    │  │    ┌──┘    ││  │    │
│    │  │     x * n      ││  │    │
│    │  │        └───┼┘  │    │
│    │  └────────┘    │    │
│    └─────────────┘    │
│                                      │
└───────────────────┘

        図 : 無名関数 (fun) 内の変数

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

これは def で定義された局所的な関数も同じです。timesElement は次のように書き換えることができます。

scala> def timesElement1(n: Int, xs: List[Int]): List[Int] = {
     | def timesN(x: Int): Int = x * n
     | xs.map(timesN)
     | }
def timesElement1(n: Int, xs: List[Int]): List[Int]

scala> timesElement1(10, List(1, 2, 3, 4, 5))
val res1: List[Int] = List(10, 20, 30, 40, 50)

局所関数 timesN は timesElement 内で定義されているので、timesN から timesElement の引数 n を参照することができます。

●クロージャ

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

Scala の関数はカリー化できるので、関数を返す関数はとても簡単に作成することができます。また、Scala は関数型言語でもあるので、当然ですがクロージャも使うことができます。Scala でクロージャを生成するには「無名関数」を使うか、局所的な関数を定義して、その関数を返します。たとえば、「引数を n 倍する関数」を生成する関数は、無名関数を使うと次のようになります。

scala> def foo(n: Int): (Int => Int) = (x: Int) => x * n
def foo(n: Int): Int => Int

scala> val foo10 = foo(10)
val foo10: Int => Int = $Lambda$1094/0x0000000840608840@47f0f414

scala> foo10(1)
val res2: Int = 10

scala> foo10(2)
val res3: Int = 20

scala> val foo5 = foo(5)
val foo5: Int => Int = $Lambda$1094/0x0000000840608840@10ba9780

scala> foo5(10)
val res4: Int = 50

scala> foo5(20)
val res5: Int = 100

関数 foo は引数を n 倍する関数を生成します。関数 foo の型は、引数 Int を受け取り Int => Int という関数を返すことを表しています。変数 foo10 に foo(10) の返り値をセットします。すると、foo10 は引数を 10 倍する関数として使うことができます。同様に、変数 foo5 に foo(5) の返り値をセットすると、foo5 は引数を 5 倍する関数になります。

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

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

また、def で局所的な関数を定義して、その関数を返すとクロージャを生成することができます。def を使った例を示します。

scala> def foo(n:Int):(Int => Int) = {
     | def bar(x:Int):Int = x * n
     | bar
     | }
def foo(n: Int): Int => Int

scala> val foo20 = foo(20)
val foo20: Int => Int = $Lambda$1106/0x0000000840566840@5605a59b

scala> foo20(11)
val res6: Int = 220

scala> foo20(22)
val res7: Int = 440

def で局所関数 bar を定義して、bar を返します。すると、foo は「引数を n 倍する関数」を生成する関数になります。

もっとも、Scala では関数を部分適用するだけで同様の関数を作ることができます。

scala> def foo(n: Int)(x: Int): Int = n * x
def foo(n: Int)(x: Int): Int

scala> val foo100 = foo(100)_
val foo100: Int => Int = $Lambda$1112/0x0000000840107040@8829ecd

scala> foo100(111)
val res8: Int = 11100

scala> foo100(222)
val res9: Int = 22200

このように、Scala は関数の部分適用により目的の関数を簡単に生成することができます。

●環境の仕組み

クロージャを理解する場合、環境を「連想リスト (association list : a-list)」で考えるとわかりやすいと思います。

一般に、関数を呼び出す場合、関数を評価するための環境は空リストです。最初に、引数がこの環境に追加されます。val や var で定義される局所変数もこの環境に追加されます。もしも、環境に該当する変数が存在しない場合は大域変数を参照します。

たとえば、foo(5) と呼び出すと環境は次のようになります。

foo(5) ==> 環境 : [(n, 5)]

連想リストのキー n が変数名で、その値が 5 です。クロージャを生成するとき、この連想リストを保持すると考えてください。そして、クロージャを評価するときは、保存していた環境を使います。したがって、foo5(11) を評価すると、環境 [(n, 5)] に引数 x の値が追加され、[(x, 11), (n, 5)] になります。この環境で式 n * x を評価するので、5 * 11 = 55 を返すわけです。

関数の評価が終了すると、環境に追加された変数は削除されます。foo5(11) の評価で追加された変数は x なので、(x, 11) が削除され [(n, 5)] になります。このように、クロージャに保存された環境は変化しません。

●ジェネレータ

最後に、クロージャの応用例として「ジェネレータ (generator)」というプログラムを紹介しましょう。ジェネレータは、呼び出されるたびに新しい値を生成していきます。Scala の場合、ジェネレータと同じことは「イテレータ (Iterator)」を使って行うことができますが、クロージャでも簡単にプログラムすることができます。

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

リスト : 奇数を発生するジェネレータ

object sample0504 {

  var prevNumber = -1

  def genOddNumber(): Int = {
    prevNumber += 2
    prevNumber
  }
}
scala> :load sample0504.scala
// defined object sample0504

scala> import sample0504._

scala> for (i <- 1 to 10) println(genOddNumber())
1
3
5
7
9
11
13
15
17
19

フィールド変数 prevNumber は、genOddNumber が返した値を記憶します。新しい値は、この prevNumber に 2 を足せばいいのです。

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

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

リスト : ジェネレータを作る関数

  // sample0504 に追加
  def makeGen(): (() => Int) = {
    var prevNumber = -1
    () => {
      prevNumber += 2
      prevNumber
    }
  }

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

scala> val g1 = makeGen()
val g1: () => Int = sample0504$$$Lambda$1153/0x0000000840646c40@174e1b99

scala> g1()
val res0: Int = 1

scala> g1()
val res1: Int = 3

scala> g1()
val res2: Int = 5

scala> g1()
val res3: Int = 7

scala> val g2 = makeGen()
val g2: () => Int = sample0504$$$Lambda$1153/0x0000000840646c40@3958db82

scala> g2()
val res4: Int = 1

scala> g2()
val res5: Int = 3

scala> g2()
val res6: Int = 5

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

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

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

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

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

リスト : ジェネレータのリセット

  // sample0504 に追加
  def makeGen1(): (() => Int, () => Unit) = {
    var prevNumber = -1
    (() => {
       prevNumber += 2
       prevNumber
     },
     () => prevNumber = -1)
  }

返り値の型はタプルで、奇数列を返す関数 () => Int とジェネレータをリセットする関数 () => Unit を格納します。どちらの関数も無名関数を使って簡単に定義することができます。あとは、それをタプルに格納して返すだけです。

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

scala> val (gen, reset) = makeGen1()
val gen: () => Int = sample0504$$$Lambda$1193/0x0000000840667440@5f67181f
val reset: () => Unit = sample0504$$$Lambda$1194/0x0000000840667840@1169fdfd

scala> gen()
val res0: Int = 1

scala> gen()
val res1: Int = 3

scala> gen()
val res2: Int = 5

scala> reset()

scala> gen()
val res4: Int = 1

scala> gen()
val res5: Int = 3

scala> gen()
val res6: Int = 5

scala> gen()
val res7: Int = 7

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

クロージャは少し難しいかもしれませんが、 便利で面白い機能です。少々歯応えがありますが、 これもプログラミングの面白いところだと思います。興味のある方はいろいろと試してみてください。

●イテレータ

ご参考までに、Scala のイテレータを簡単に説明しておきます。Scala の場合、データ (オブジェクト) にメソッド iterator があれば、そのデータをイテレータに変換することができます。イテレータの型は "Iterator[型]" で表され、メソッド has.Next でデータの有無を判定し、メソッド next でイテレータからデータを順番に取り出すことができます。

簡単な例を示しましょう。

scala> val iter = Range(1, 10, 2).iterator
val iter: Iterator[Int] = <iterator>

scala> iter.hasNext
val res8: Boolean = true

scala> iter.next()
val res9: Int = 1

scala> iter.next()
val res10: Int = 3

scala> iter.next()
val res11: Int = 5

scala> iter.next()
val res12: Int = 7

scala> iter.next()
val res13: Int = 9

scala> iter.hasNext
val res14: Boolean = false

数列を表す Range を iterator でイテレータに変換し、変数 iter にセットします。すると、iter.next が数列の要素を一つずつ順番に取り出すことができます。リストや配列もイテレータに変換することができますし、自分でイテレータを作ることもできます。詳細はオブジェクト指向のところで説明する予定です。


初版 2014 年 8 月 2 日
改訂 2024 年 12 月 17 日