大人のためのブラックボックス読解講座――クロージャとオブジェクトの微妙な関係(その2)プログラミング言語の進化を追え(1/3 ページ)

前回に引き続き、Scheme言語の処理系、Gaucheを開発している川合史朗氏が、クロージャの機能を検証し、関数型言語とオブジェクト指向言語の関係について解説していきます。今回は、クロージャとオブジェクトのより深淵を探求します。

» 2007年03月30日 12時00分 公開
[川合史朗,ITmedia]

抽象化ツールとしてのクロージャ

 C++的なオブジェクトの世界では、オブジェクトの実体とは「ひとかたまりの構造体としてメモリ上に置かれたインスタンス変数の値」にすぎません。オブジェクトのポインタを取れば、それは事実上、その構造体へのポインタを持っていることになります。クロージャを「関数」中心で見ていると、その実体は「オブジェクト」の実体とは異質なもののように思えるでしょう。

 確かにクロージャのナイーブな「実装」は、関数ポインタと環境へのポインタをくっつけたものです。しかし、クロージャをそのように実装する「必要」はありません。

クロージャの実装戦略

 C++的オブジェクトは、その歴史的いきさつ(端的に言えばC言語との互換性確保)のゆえに、インスタンス変数がメモリ上にひとかたまりで存在し、またその寿命もオブジェクト自身の寿命と一致する必要があります。たとえそのインスタンス変数が、ほんの一部のメソッドからしか触れられていなくても、あるいは一時的にしか使われなくても、です。C++の基になったCの構造体が、抽象的な複合型を表現するだけでなく、メモリ上のレイアウトの指定にも使われていた名残と言えます。

 これに対しクロージャは、その中から参照している変数がどこに存在しても構いません(クロージャ自身が生きている間、本体から見えさえすれば)。例えば、初期化後に変更されないと分かっているローカル変数は、その初期化値をクロージャ内にコピーすることで消去可能です。mapに渡すクロージャのように、生存期間がローカル環境より短いことがはっきりしている場合は、ローカルスタックに置くこともできます。そして何より、クロージャが使われる個所が静的に(つまり、式を読み込んで内部ツリーに展開した時点で)すべて判明している場合は、本体をインライン展開してしまうことでクロージャそのものの存在を消去することさえ可能なのです。

部品としてのクロージャ

 これらクロージャの最適化戦略は、関数型言語かいわいでは20〜30年ほど前に盛んに研究されたことで、いまでは暗黙の了解となっています。そのため、関数型言語のプログラマーはクロージャを書くときに、オブジェクトを「作っている」という感覚(例えばCのmallocや、C++/Javaのnewを呼ぶような感覚)をほとんど持ちません。結果として「何か」がアロケートされてしまうことはありますが、それは(1)どうやっても避けられないアロケーションか、もしくは(2)コンパイラが手を抜いているか、のいずれかなので、気にしても仕方がない*のです。

 関数型言語のプログラマーにとっては、クロージャとはむしろ「穴の空いたコードのブロック」です。穴は仮引数であり、用途に応じて実際の値を埋め込む個所です。コードに似たようなパターンを見つけると、変化する部分を穴にして、共通部分を関数としてくくり出します。そして変化する部分を、実引数として与えてやるのです。

 実例を見てみましょう。与えられた数の集合numbersをすべて足した値を返す関数sum-of-numbersは、リスト1のように書けます。一方、与えられた数の集合から、ある数値より大きいものだけを選ぶ関数more-thanは、リスト2のように書けます*

(define (sum-of-numbers numbers)

  (define (loop sum nums)

    (if (null? nums)

      sum

      (loop (+ (car nums) sum) (cdr nums))))

  (loop 0 numbers))


リスト1 与えられた数の集合numbersをすべて足した値を返す関数sum-of-numbers

(define (more-than n numbers)

  (define (loop out nums)

    (if (null? nums)

      out

      (loop (if (> (car nums) n)

              (cons (car nums) out)

              out)

            (cdr nums))))

  (loop '() numbers))


リスト2  与えられた数の集合から、ある数値より大きいものだけを選ぶ関数more-than

 このように、ローカル関数loopで再帰するのは非常によく見るパターンです。両者を見比べると、リスト3のような共通構造があると分かります。本体内で変化する部分は、<初期値>と<経過と(car nums)を使った式>の部分のみです。それらを穴として引数で渡すことにすれば、共通構造を関数としてくくり出すことができます(リスト4)

(define (名前 numbers)

  (define (loop 経過 nums)

    (if (null? nums)

      経過

      (loop <経過と(car nums)を使った式>

            (cdr nums))))

  (loop <初期値> numbers))


リスト3 sum-of-numbersとmore-thanの共通構造

(define (fold proc seed lis)

  (define (loop seed lis)

    (if (null? lis)

      seed

      (loop (proc seed (car lis)) (cdr lis))))

  (loop seed lis))


リスト4  sum-of-numbersとmore-thanの共通構造を関数としてくくり出した部品fold

 この部品foldに、変化する部分を外から与えてやることで、元のsum-of-numbersとmore-thanを再構成できます*(リスト5)。これはほんの一例に過ぎません。同様にして、クロージャとはあらゆる個所で式を「部品化」してゆく手段なのです。

(define (sum-of-numbers numbers)

  (fold (lambda (sum elt) (+ sum elt)) 0 numbers))


(define (more-than n numbers)

  (fold (lambda (out elt)

          (if (> elt n) (cons elt out) out))

        '() numbers))


リスト5 再構成したsum-of-numbersとmore-than

 そもそも、基本的な部品化の手段であるローカル変数という概念自体を、Schemeの言語仕様はクロージャを用いて定義しています。ローカル変数x、yを導入するリスト6の式は、定義上、x、yを仮引数とするクロージャを作って直ちに初期値10、20を実引数として呼び出しているのと等価とされています(リスト7)

(let ((x 10)  ← 変数xに10を束縛(xを定義)

      (y 20)) ← 変数yに20を束縛(yを定義)

  (sqrt (+ (* x x) (* y y))))


リスト6 ローカル変数x、yを定義する式(letという構文)

((lambda (x y) (sqrt (+ (* x x) (* y y))))

  10 20)


リスト7 let構文の定義による、リスト6のプログラムの「意味」

 もちろん、多くのScheme処理系はlet構文を処理するためにクロージャの実体をアロケートしたりはしません(処理効率が悪くなるためです)。ただし、このようにクロージャを介した定義を行っておけば、クロージャ最適化のアルゴリズムを一様に適用できます。

 クロージャによるオブジェクトの実装も同様です。プログラムの字面通りにクロージャの実体が生成される必要はありません。「クロージャによって(オブジェクトなどを)定義すれば、一般的な意味解析を適用できるようになる」という事実が、関数型のアプローチからは極めて有用なのです。

       1|2|3 次のページへ

Copyright © ITmedia, Inc. All Rights Reserved.

注目のテーマ