M.Hiroi's Home Page

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

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


Copyright (C) 2008-2020 Makoto Hiroi
All rights reserved.

はじめに

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

●レキシカルスコープ

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

# let x = "global\n";;
val x : string = "global\n"
# let foo () = print_string x;;
val foo : unit -> unit = <fun>
# foo ();;
global
- : unit = ()

OCaml の場合、引数のない関数は unit を使って定義します。その関数を呼び出すときも、引数として unit を渡します。

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

# let foo1 () = let x = "local\n" in foo ();;

... 警告メッセージ ...

val foo1 : unit -> unit = <fun>
# foo1();;
global
- : unit = ()

局所変数 x を使っていないという警告が出ますが気にしないでください。foo1 を実行すると大域変数の値 global を表示しました。このように、foo1 で定義した局所変数 x は、foo から参照することはできません。図 1 を見てください。

図 1 では変数の有効範囲を枠で表しています。foo1 の let で定義した局所変数 x は、let の枠の中でのみ有効です。もしも、この枠で変数が見つからない場合は、ひとつ外側の枠を調べます。順番に外側の枠を調べていくと、最後には関数定義の枠に行き着きます。ここで変数(引数)が見つからない場合は大域変数を調べます。

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

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

┌──────  OCaml system  ──────┐ 
│                                        │
│              大域変数  x ←────┐  │
│                                    │  │
│  ┌→┌─ 関数 foo ──────┐  │  │
│  │  │                ┌───┼─┘  │
│  │  │   print_string x       │      │
│  │  │                        │      │
│  │  └────────────┘      │
│  │  ┌─ 関数 foo1  ─────┐      │
│  │  │                        │      │
│  │  │  ┌─let : x ───┐  │      │
│  │  │  │                │  │      │
│  └─┼─┼─ foo()        │  │      │
│      │  └────────┘  │      │
│      └────────────┘      │
│                                        │
└────────────────────┘

        図 1 : レキシカルスコープ

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

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

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

let times_element n xs =
  List.map (fun x -> x * n) xs

匿名関数の引数は x だけなので、変数 n は大域変数を参照するように思われるかもしれません。ところが、変数 n は関数 times_element の引数 n を参照するのです。これを図に示すと、次のようになります。

┌────── OCaml system ──────┐
│                                      │
│    ┌─ times_element : n xs ─┐    │
│    │                  ↑      │    │
│    │                  └─┐  │    │
│    │  ┌── fun : x  ─┐│  │    │
│    │  │          ↑    ││  │    │
│    │  │    ┌──┘    ││  │    │
│    │  │     x * n      ││  │    │
│    │  │        └───┼┘  │    │
│    │  └────────┘    │    │
│    └─────────────┘    │
│                                      │
└───────────────────┘

        図 2 : 匿名関数内の変数

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

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

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

let times_element n xs =
  let timesN x = x * n in
  List.map timesN xs

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

●クロージャ

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

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

# let foo n = fun x -> n * x;;
val foo : int -> int -> int = <fun>
# let foo10 = foo 10;;
val foo10 : int -> int = <fun>
# foo10 100;;
- : int = 1000
# let foo5 = foo 5;;
val foo5 = int -> int = <fun>
# foo5 11;;
- : int = 55

関数 foo は引数を n 倍する関数を生成します。関数 foo の型 int -> int -> int は int -> (int -> int) を意味しています。これは引数 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 倍した結果を返すのです。

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

# let foo n = let bar x = n * x in bar;;
val foo = int -> int -> int = <fun>
# let foo100 = foo 100;;
val foo100 : int -> int = <fun>
# foo100 11;;
- : int = 1100

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

もっとも、OCaml では関数をカリー化するだけで同様の関数を作ることができます。

# let foo n x = n * x;;
val foo : int -> int -> int = <fun>
# let foo10 = foo 10;;
val foo10 : int -> int = <fun>
# foo10 5;;
- : int = 50
# let foo100 = foo 100;;
val foo100 : int -> int = <fun>
# foo100 5;;
- : int = 500

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

●連想リスト

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

連想リストは Lisp でよく用いられるデータ構造で、OCaml ではキーとデータの組を要素とするリストで実現することができます。データ型で記述すると ('a * 'b) list になり、'a がキーで 'b がデータに対応します。

                    ┌────┬────┬────┬──→ データ 
                    │        │        │        │
連想リスト => [("a", 1); ("b", 2); ("c", 3); ("d", 4)]
                │        │        │        │
                └────┴────┴────┴──→ キー

                図 3 : 連想リストの構造

上図の場合、文字列 "a", "b", "c", "d" がキーで、整数 1, 2, 3, 4 がデータとなります。OCaml の List モジュールには連想リストを操作する関数が用意されています。

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

たとえば、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)] になります。このように、クロージャに保存された環境は変化しません。

ただし、Common Lisp や Scheme のように、変数の値を書き換えることができる処理系では、クロージャに保存された変数の値を変更することが可能です。興味のある方はお気楽 Common Lisp プログラミング入門「レキシカルスコープとクロージャ」を読んでみてください。

●ダイナミックスコープ

ところで、伝統的な Lisp はレキシカルスコープではなくダイナミックスコープです。ここで簡単にダイナミックスコープについて説明します。もしも、OCaml がダイナミックスコープであれば、最初の例題で示した関数 foo は関数 foo1 から呼び出されると、foo1 の局所変数にアクセスすることができます。これを図に示すと図 4 のようになります。

ダイナミックスコープの場合、局所変数はひとつの連想リストで管理されていると考えてください。この場合、キーが局所変数を表し、データがその値に対応します。局所変数にアクセスするときは、この連想リストから該当する変数を探すのです。見つからない場合は大域変数を検索します。

関数呼び出しや let などで局所変数が定義されたときに、変数とその値が連想リストの先頭へ追加されます。そして、関数や let の評価が終了したときに、連想リストから変数が削除されます。関数が呼び出されるたびに、新しい変数が連想リストに追加されますが、呼び出した側で定義した局所変数も、この連想リストの中に残っています。

たとえば、(1) のように関数 foo を呼び出した場合、関数の引数がなくて局所変数の定義もないので連想リストは空です。ところが、(2) のように関数 foo1 を呼び出した場合、局所変数 x が定義されているので、連想リストに (x, "local") がセットされます。この状態で関数 foo が呼び出されると、連想リストには foo1 で定義した (x, "local") が残っているので、foo では x の値が "local" となるのです。

このように、ダイナミックスコープでは、変数のアクセスは関数を評価する順番に左右されます。したがって、関数の中で定義されていない変数があっても、それがグローバル変数として扱われるとは限らないのです。

ダイナミックスコープで便利なところは、ほかの関数から他の関数の局所変数にアクセスできるところです。そのかわり、プログラムを見ただけでは、どの変数にアクセスしているのかわからないという欠点があります。プログラムの可読性の点でいえば、レキシカルスコープの方が優れていると思います。

┌──────────────┐    ┌─定義された局所変数─┐
│let foo () = print_string x │    │  []                  │
└──────────────┘    └───────────┘
                                       定義された変数はなし
    (1) bar を評価する

┌──────────────┐    ┌─定義された局所変数─┐
│let foo1 () =               │─→│  [(x, "local")]      │
│  let x = "local" in foo () │    └───────────┘
└──────────────┘
                        ↓  呼び出し
┌──────────────┐    ┌─定義された局所変数─┐
│let foo () = print_string x │←─│  [(x, "local")]      │
└──────────────┘    └───────────┘
      x は "local" となる              foo で定義した変数 a

    (2) foo から bar を呼び出す

                    図 4 : ダイナミックスコープ

●関数の合成

最後に、関数 f(x) と g(x) を合成して新しい関数 h(x) を作ることを考えてみましょう。関数 h(x) を次のように定義します。

h(x) = g( f(x) )

たとえば、f(x) = 2 * x + 1, g(y) = y * y + 3 * y とすると、h(x) は次のようになります。

h(x) = g( f(x) )
     = (2 * x + 1) * (2 * x + 1) + 3 * (2 * x + 1)
     = 4 * x * x + 4 * x + 1 + 6 * x + 3
     = 4 * x * x + 10 * x + 4

実際のプログラムは数式を展開するのではなく、f(x) の評価結果を g(x) に渡すだけなので簡単です。第 1 引数と第 2 引数に関数 f と g を受け取り、それらを第 3 引数に適用する関数 compose は次のようになります。

# let compose f g x = g (f x);;
val compose : ('a -> 'b) -> ('b -> 'c) -> 'a -> 'c = <fun>

関数 f(x) の返り値が関数 g(x) の引数になるので、関数 g(x) が受け付ける値でなければいけません。そうでないと関数を合成することはできません。この条件を関数 compose の型が表しています。関数 f の返り値が型 'b ならば、関数 g の引数も型は 'b でなければいけません。

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

# let foo x = 2 * x + 1;;
val foo = int -> int = <fun>
# let bar y = y * y + 3 * y;;
val bar = int -> int = <fun>
# bar (foo 4);;
- : int = 108
# let baz = compose foo bar;;
val baz : int -> int = <fun>
# baz 4;;
- : int = 108

関数 foo と bar を定義します。foo と bar の合成は bar( foo( x ) ) と表すことができます。実際に 4 を計算すると 108 になります。この関数は compose で合成することができます。compose foo bar の返り値を変数 baz に束縛すると、baz を合成関数として使うことができます。

●問題

次の関数を定義してください。

  1. リスト xs から先頭文字が c の文字列を削除する関数 remove_string c xs
  2. リスト xs から n と割り切れる要素を削除する関数 remove_number n xs
  3. リスト xs を反転してから、リスト ys を連結する関数 rev_append xs ys
  4. リスト xs の要素に述語 pred を適用し、一つでも真を返す要素があれば真を返す関数 any pred xs
  5. リスト xs の要素に述語 pred を適用し、全てが真の場合に真を返す関数 every pred xs















●解答

リスト : 解答例

let rec remove_string c xs =
  List.filter (fun s -> s.[0] <> c) xs

let rec remove_number n xs =
  List.filter (fun x -> x mod n <> 0) xs

let rec any pred = function
    [] -> false
  | x::xs when not (pred x) -> any pred xs
  | _ -> true

let rec every pred = function
    [] -> true
  | x::xs when pred x -> every pred xs
  | _ -> false

let rec rev_append xs ys =
  match xs with
    [] -> ys
  | x::xs1 -> rev_append xs1 (x::ys)
val remove_string : char -> string list -> string list = <fun>
val remove_number : int -> int list -> int list = <fun>
val any : ('a -> bool) -> 'a list -> bool = <fun>
val every : ('a -> bool) -> 'a list -> bool = <fun>
val rev_append : 'a list -> 'a list -> 'a list = <fun>
# remove_string 'A' ["abc"; "ABC"; "def"];;
- : string list = ["abc"; "def"]
# remove_string 'A' ["Abc"; "ABC"; "def"];;
- : string list = ["def"]
# remove_string 'A' ["abc"; "aBC"; "def"];;
- : string list = ["abc"; "aBC"; "def"]

# remove_number 2 [1; 2; 3; 4; 5; 6; 7];;
- : int list = [1; 3; 5; 7]
# remove_number 3 [1; 2; 3; 4; 5; 6; 7];;
- : int list = [1; 2; 4; 5; 7]
# remove_number 8 [1; 2; 3; 4; 5; 6; 7];;
- : int list = [1; 2; 3; 4; 5; 6; 7]

# any (fun x -> x mod 2 = 0) [1; 2; 3; 5; 7; 9];;
- : bool = true
# any (fun x -> x mod 2 = 0) [1; 3; 5; 7; 9];;
- : bool = false

# every (fun x -> x mod 2 = 0) [2; 4; 6; 8; 10];;
- : bool = true
# every (fun x -> x mod 2 = 0) [2; 4; 5; 8; 10];;
- : bool = false

# rev_append [1; 2; 3] [4; 5; 6];;
- : int list = [3; 2; 1; 4; 5; 6]
# rev_append [] [4; 5; 6];;
- : int list = [4; 5; 6]

初版 2008 年 6 月 21 日
改訂 2020 年 6 月 28 日