- クロージャは関数本体と変数環境を束ねたデータ構造として見ると、その性質が見えやすくなります
- C言語で再現しようとすると、関数ポインタと環境構造体が必要になり、単なる関数では足りないことが明らかになります
- クロージャとオブジェクトは実装レベルでは同じ形をとります。
「関数が環境を持つか、データが操作を持つか」という重心の違いだけがあります - Common Lispでは、クロージャはリストやGCと並んで言語の実行モデルに組み込まれた機構です
1. クロージャの最小構造
Common Lispで make-adder を書くと、こういう形になります。
(defun make-adder (n)
#'(lambda (x)
(+ x n)))Code language: Lisp (lisp)
(setq add10 (make-adder 10))
(funcall add10 5) ; => 15Code language: Lisp (lisp)
make-adder の呼び出しはすでに終わっているのに、返ってきた関数は n = 10 を覚えています。
これがクロージャです。
概念的には2つのものが合わさっています。
関数本体: (lambda (x) (+ x n))
変数環境: n = 10Code language: HTTP (http)
コードが書き換わっているわけではなく、関数が n への参照を保持しています1。
1.1. コールバックこそクロージャの実用的な中心
add10 のような例はクロージャの仕組みを説明するには適しています。
実際のCommon Lispでクロージャが力を発揮するのは、関数を後で別の場所で呼ぶときに、その関数が作られた文脈を忘れない、という場面です。
(defun register-handler (name table)
(setf (gethash name table)
#'(lambda (value)
(format t "~A received ~A~%" name value))))Code language: Lisp (lisp)
このラムダ式はすぐに実行されません。
ハッシュ表に保存され、後から取り出されて呼ばれます。
それでも作られたときの name を保持しています。
(defparameter *handlers* (make-hash-table :test #'equal))
(register-handler "alpha" *handlers*)
(register-handler "beta" *handlers*)
(funcall (gethash "alpha" *handlers*) 100)
; => alpha received 100
(funcall (gethash "beta" *handlers*) 200)
; => beta received 200Code language: Lisp (lisp)
Cで同じことをするには、コールバック関数とその文脈を別々に渡す設計が必要です。
callback_function(context, value);Code language: C++ (cpp)
Common Lispのクロージャでは、context が関数の内側に閉じ込められます。
呼び出し側は value だけ渡せばよく、文脈の管理が呼び出し側に漏れません。
mapcar に渡す add10 のような使い方を「部分適用」と呼ぶなら、ハンドラやコールバックへの使い方は「文脈保存」です2。
Common Lispの日常的な設計では、文脈保存としての使い方のほうがクロージャらしさを感じる場面が多いです。
1.2. 関数の入出力に「環境」が加わる
普通の関数の入出力はシンプルです。
返り値 = f(引数)
状態を持つクロージャは、これとは違います。
返り値, 更新後の環境 = f(引数, 現在の環境)
カウンタで実行の流れを追うと、こうなります。
count = 0
1回目: 入力 count=0 、 返り値=1, count=1
2回目: 入力 count=1 、 返り値=2, count=2
3回目: 入力 count=2 、 返り値=3, count=3
Common Lispの表面上の呼び出しには count は現れません。
(funcall counter)Code language: Lisp (lisp)
counter が背後に count という環境を持ち、それを読み書きしています。
環境は暗黙の入力であり、暗黙の出力でもあります3。
読み取り専用のクロージャと状態を更新するクロージャを区別すると、次のようになります。
| 種別 | 入力 | 出力 |
|---|---|---|
| 通常の関数 | 引数のみ | 返り値 |
| 読み取り専用クロージャ | 引数と環境(読む) | 返り値 |
| 状態更新クロージャ | 引数と環境(読む) | 返り値と更新後の環境 |
2. C言語で作ると何が要るか
C言語には普通の関数しかありません。
int add(int x, int n) {
return x + n;
}Code language: C++ (cpp)
この関数は呼び出し時に n を渡してもらう必要があります。n = 10 を覚えた「add10」という関数をそのまま作ることができません。
局所変数も同じです。
関数が終われば消えます。static 変数は残りますが、ひとつの関数に固定された共有状態です4。
int counter(void) {
static int count = 0;
return ++count;
}Code language: C++ (cpp)
これでは、独立したカウンタを複数作れません。counter() を呼ぶたびに同じ count が増えるだけです。
クロージャで押さえておきたいのは「同じ関数本体を使いながら、別々の環境を持つ関数値を複数作れる」点です。
Common Lispなら、こうなります。
(setq c1 (make-counter))
(setq c2 (make-counter))
(funcall c1) ; => 1
(funcall c1) ; => 2
(funcall c2) ; => 1 ← c1とは独立しているCode language: Lisp (lisp)
これをCで再現するには、関数だけでは足りません。
2.1. C言語でのクロージャ実装
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int n;
} AdderEnv;
int add(void *env, int x) {
AdderEnv *e = (AdderEnv *)env;
return x + e->n;
}
typedef struct {
int (*func)(void *env, int x);
void *env;
} Closure;
Closure make_adder(int n) {
AdderEnv *env = malloc(sizeof(AdderEnv));
env->n = n;
Closure c;
c.func = add;
c.env = env;
return c;
}
void free_closure(Closure c) {
free(c.env);
}
int main(void) {
Closure add10 = make_adder(10);
Closure add20 = make_adder(20);
printf("%d\n", add10.func(add10.env, 5)); /* 15 */
printf("%d\n", add20.func(add20.env, 5)); /* 25 */
free_closure(add10);
free_closure(add20);
return 0;
}Code language: C++ (cpp)
Closure 構造体がクロージャ本体です。
| クロージャの要素 | Cでの実装 |
|---|---|
| 関数本体 | 関数ポインタ func |
| 変数環境 | 環境構造体 env |
| 環境の保持 | malloc でヒープに確保 |
| 自動的な環境管理 | なし。 |
手動で free が必要 |
make_adder(10) と make_adder(20) は同じ関数ポインタを使いながら、別々の AdderEnv を持ちます5。
Common Lispでクロージャが自然にやっていることを、Cでは構造体と明示的なメモリ管理で実現しています。
malloc が必要なのは、関数呼び出し後も n を残すためです。
通常の局所変数はスタックに置かれ、関数が終わると消えます。
ヒープに確保することで、make_adder が終わった後も環境が生き続けます6。
2.2. クロージャとオブジェクトは何が違うか
Cで書くと、クロージャとオブジェクトが同じ形になることに気づきます。
クロージャの構造体はこうです。
typedef struct {
int (*func)(void *env, int x);
void *env;
} Closure;Code language: C++ (cpp)
オブジェクト風の構造体はこうです。
typedef struct {
int value;
void (*print)(void *self);
void (*set)(void *self, int value);
} Object;Code language: C++ (cpp)
どちらも「値と関数ポインタを束ねた構造体」で、実装レベルでは同形です。
違いは重心にあります。
クロージャは「この関数は何を覚えているか」を中心に考えます。
オブジェクトは「このデータはどんな操作を持つか」を中心に考えます。
Common Lispで言えば、クロージャはひとつの関数のために環境を閉じ込めます。
複数の操作を通じて同じ状態を扱いたいなら、Common Lisp Object SystemであるCLOSのほうが自然な設計になります7。
2.3. 言語の抽象レイヤーとして見る
CPUの命令セットには「関数呼び出し」という概念はありません。
レジスタ、メモリ、ジャンプ命令があるだけです。
Cのコンパイラが、スタックフレームと呼び出し規約を使って「関数」を実現しています。
CPU レジスタ、メモリ、ジャンプ
C言語 関数、引数、局所変数、スタックフレーム(コンパイラが実現)
Cのクロージャ 関数ポインタと環境構造体(プログラマが作る)
Common Lispでは、クロージャはプログラマが作るものではありません。
(defun make-adder (n)
#'(lambda (x)
(+ x n)))Code language: Lisp (lisp)
ラムダ式が外側のレキシカル変数を参照した時点で、処理系が自動的に環境を保持します8。
環境構造体を定義する必要も、malloc で確保する必要も、free で解放する必要もありません。
ここでGCの役割が出てきます。
Cでは、クロージャ的な構造体を作ったら free の責任はプログラマにあります。
Common Lispでは、クロージャが参照している環境は必要な間だけ保持され、参照されなくなればGCが回収します。
クロージャとGCは設計として対になっています。
クロージャが動的に環境を生成し、GCがその寿命を管理します。
LispにGCが早くから必要とされた理由のひとつはここにあります9。
3. まとめ
クロージャをCで実装しようとすると、単なる関数では足りず、関数ポインタと環境構造体が必要になります。
この事実が、クロージャを「関数」ではなく「実行可能なデータ構造」として見る根拠になります。
Common Lispでは、この構造が言語の実行モデルに組み込まれています。
プログラマが構造体を定義せず、メモリを管理しなくても、ラムダ式が外側の変数を参照した時点でクロージャが成立します。
リスト、GC、レキシカルスコープと並んで、言語処理系が支える抽象機構のひとつです。
- クロージャという用語はPeter Landinが1964年、SECDマシンを記述した論文「The Mechanical Evaluation of Expressions」で定義しました。環境部分(Environment part)と制御部分(Control part)を持つものとして表現されています。言語機能として最初に実装されたのは1970年のPAL言語とされています。 – Closure (computer programming) – Wikipedia
- 部分適用とは、複数の引数を持つ関数の一部の引数を先に固定して、残りの引数を受け取る新しい関数を作る手法です。カリー化と混同されることがありますが、部分適用は一度に複数の引数を固定できる点で異なります。Common Lispのalexandriaライブラリは
curryとrcurry関数として部分適用を提供しています。 – Partial application – Wikipedia - Common LispはLisp-2と呼ばれる設計で、関数名前空間と変数名前空間が分離しています。クロージャを変数に束縛した場合、関数として呼び出すには
funcallが必要です。Schemeは同じ名前空間を共有するLisp-1であるためfuncallが不要で、カリー化や部分適用の呼び出し構文が自然になります。 – Common Lisp – HandWiki - C言語の
static局所変数はプログラムの実行期間中メモリに保持され、その関数のすべての呼び出しで同一のメモリ領域を共有します。インスタンスごとに独立した状態を持つには、ヒープ上にメモリを確保し、構造体に状態を格納する設計が必要になります。 – Dynamic Memory Allocation in C – GeeksforGeeks - コード中の
void *envは型を持たない汎用ポインタです。C89(ANSI C 1989年)から標準化されており、任意の型のポインタに変換できます。mallocの戻り値もvoid *であるため、確保したメモリをどの型として扱うかは使用側が明示的にキャストして決定します。 – void Pointer in C – GeeksforGeeks - スタックはCPUが関数呼び出しのたびにフレームを積み、関数が戻ると自動的に破棄されます。ヒープは
mallocで明示的に確保し、freeを呼ぶまで保持されます。スタックとヒープはメモリの両端から互いに向かって伸びるように配置されており、互いに成長できる領域を確保する設計になっています。 – The Stack, The Heap, and Dynamic Memory Allocation – CS 3410, Cornell - CLOSは1986年から1988年にかけて設計され、1994年のANSI標準(ANSI INCITS 226-1994)に組み込まれました。多重ディスパッチと多重継承を持つ動的オブジェクトシステムで、メソッドはクラスではなくジェネリック関数に属します。Common Lispはこの標準の採択によって、ANSIの認定を受けた最初のオブジェクト指向言語仕様になりました。 – Common Lisp Object System – Wikipedia
- Common Lispがデフォルトでレキシカルスコープを採用したのは、Schemeの影響によります。以前のLisp実装(ZetaLispやFranz Lispなど)は解釈系で動的スコープを使っていました。現在も動的スコープの変数は
specialと宣言することで使えますが、宣言なしの局所変数はすべてレキシカルスコープです。 – Common Lisp – HandWiki - ガベージコレクションはJohn McCarthyが1959年ごろLisp実装のために発明しました。コンスセルを動的に生成・破棄するLispの設計では手動のメモリ管理が困難であったため、マーク・アンド・スイープアルゴリズムが考案されました。「garbage collection」という語の初出はMcCarthyの1960年の論文とされています。 – Garbage collection (computer science) – Wikipedia