M.Hiroi's Home Page

お気楽 F# プログラミング超入門

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


Copyright (C) 2022 Makoto Hiroi
All rights reserved.

はじめに

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

●レキシカルスコープ

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

> let x = "global";;
val x: string = "global"

> let foo () = printfn "%s" x;;
val foo: unit -> unit

> foo();;
global
val it: unit = ()

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

> let foo1 () = let x = "local\n" in foo ();;
val foo1: unit -> unit

> foo1();;
global
val it: unit = ()

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 になります。

┌─────── F# 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 を参照するのです。これを図に示すと、次のようになります。

┌─────── F# 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)」です。クロージャは評価する関数と参照可能な局所変数をまとめたものです。クロージャは関数のように実行することができますが、クロージャを生成するときに参照可能な局所変数を保持するところが異なります。参照可能な局所変数の集合を「環境」と呼ぶことがあります。

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

> let foo n = fun x -> n * x;;
val foo: n: int -> x: int -> int

> let foo10 = foo 10;;
val foo10: (int -> int)

> foo10 100;;
val it: int = 1000

> let foo5 = foo 5;;
val foo5: (int -> int)

> foo5 11;;
val it: 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: n: int -> (int -> int)

> let foo100 = foo 100;;
val foo100: (int -> int)

> foo100 11;;
val it: int = 1100

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

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

> let foo n x = n * x;;
val foo: n: int -> x: int -> int

> let foo10 = foo 10;;
val foo10: (int -> int)

> foo10 5;;
val it: int = 50

> let foo100 = foo 100;;
val foo100: (int -> int)

> foo100 5;;
val it: int = 500

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

●連想リスト

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

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

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

                図 3 : 連想リストの構造

上図の場合、文字列 "a", "b", "c", "d" がキーで、整数 1, 2, 3, 4 がデータとなります。

一般に、関数を呼び出す場合、関数を評価するための環境は空リストです。最初に、引数がこの環境に追加されます。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 はレキシカルスコープではなくダイナミックスコープです。ここで簡単にダイナミックスコープについて説明します。もしも、F# がダイナミックスコープであれば、最初の例題で示した関数 foo は関数 foo1 から呼び出されると、foo1 の局所変数にアクセスすることができます。これを図に示すと次のようになります。

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

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

┌──────────────┐    ┌─定義された局所変数─┐
│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 : ダイナミックスコープ

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

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

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

●パイプラインと関数合成

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

baz(x) = bar( foo(x) )

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

baz(x) = bar( foo(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

実際のプログラムは数式を展開するのではなく、foo(x) の評価結果を bar(x) に渡すだけなので簡単です。F# にはパイプ演算子 |> という便利な機能があり、bar( foo(x) ) という関数呼び出しを次のように表すことができます。

x |> foo |> bar

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

> let foo x = 2 * x + 1;;
val foo: x: int -> int

> let bar y = y * y + 3 * y;;
val bar: y: int -> int

> bar (foo 4);;
val it: int = 108

> 4 |> foo |> bar;;
val it: int = 108

F# の場合、関数の合成は演算子 >> を使うと簡単です。

> (>>);;
val it: (('a -> 'b) -> ('b -> 'c) -> 'a -> 'c)

左辺式の返り値が右辺式の引数になるので、右辺式が受け付ける値でなければいけません。そうでないと関数を合成することはできません。この条件を演算子 >> の型が表しています。左辺式の返り値が型 'b ならば、右辺式の引数も型は 'b でなければいけません。

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

> let baz = foo >> bar;;
val baz: (int -> int)

> baz 4;;
val it: int = 108

関数 foo と bar の合成は bar( foo( x ) ) と表すことができます。実際に 4 を計算すると 108 になります。この関数は演算子 >> で合成することができます。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: string) -> s[0] <> c) xs

let rec remove_number n xs =
  List.filter (fun x -> x % 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: c: char -> xs: string list -> string list
val remove_number: n: int -> xs: int list -> int list
val any: pred: ('a -> bool) -> _arg1: 'a list -> bool
val every: pred: ('a -> bool) -> _arg1: 'a list -> bool
val rev_append: xs: 'a list -> ys: 'a list -> 'a list
> remove_string 'A' ["abc"; "ABC"; "def"];;
val it: string list = ["abc"; "def"]

> remove_string 'A' ["Abc"; "ABC"; "def"];;
val it: string list = ["def"]

> remove_string 'A' ["abc"; "aBC"; "def"];;
val it: string list = ["abc"; "aBC"; "def"]

> remove_number 2 [1; 2; 3; 4; 5; 6; 7];;
val it: int list = [1; 3; 5; 7]

> remove_number 3 [1; 2; 3; 4; 5; 6; 7];;
val it: int list = [1; 2; 4; 5; 7]

> remove_number 8 [1; 2; 3; 4; 5; 6; 7];;
val it: int list = [1; 2; 3; 4; 5; 6; 7]

> any (fun x -> x % 2 = 0) [1; 2; 3; 5; 7; 9];;
val it: bool = true

> any (fun x -> x % 2 = 0) [1; 3; 5; 7; 9];;
val it: bool = false

> every (fun x -> x % 2 = 0) [2; 4; 6; 8; 10];;
val it: bool = true

> every (fun x -> x % 2 = 0) [2; 4; 5; 8; 10];;
val it: bool = false

> rev_append [1; 2; 3] [4; 5; 6];;
val it: int list = [3; 2; 1; 4; 5; 6]

> rev_append [] [4; 5; 6];;
val it: int list = [4; 5; 6]

初版 2022 年 3 月 12 日