- クロージャは関数本体と変数環境を束ねたデータ構造として見ると、その性質が見えやすくなります
- C言語で再現しようとすると、関数ポインタと環境構造体が必要になり、単なる関数では足りないことが明らかになります
- クロージャとオブジェクトは実装レベルでは同じ形をとります。
「関数が環境を持つか、データが操作を持つか」という重心の違いだけがあります - Common Lispでは、クロージャはリストやGCと並んで言語の実行モデルに組み込まれた機構です
1. クロージャの最小構造
プログラミングでの「クロージャ(closure:関数閉包)」は、関数オブジェクトの一種です。
関数(制御部分)とその関数が定義されている状態(環境部分)をセットにしたもので、ラムダ式や無名関数で表現されます1。。
関数の外部の変数を「閉じ込めて」おくことができるので、クロージャを使うと状態の保持、データのカプセル化、動的な関数生成が可能になります。
たとえば、Common Lispでクロージャを作るには、lambdaを使います。
(defun make-adder (n)
#'(lambda (x)
(+ x n)))Code language: Lisp (lisp)
ポイントは、lambdaの中に外部の変数 n を含んでいることです。
引数として10を与えてクロージャを作り、変数 add10 に束縛し、mapcarで呼びます。
(setq add10 (make-adder 10))
(mapcar add10 '(5 6 7))
; => (15 16 17)Code language: Lisp (lisp)
make-adder の呼び出しはすでに終わっていて n はスコープから外れているはずなのに、 add10 には n = 10 を覚えていることです。
これが「クロージャ」です。
概念的には2つのものが合わさっています。
関数本体: (lambda (x) (+ x n))
変数環境: n = 10Code language: HTTP (http)
マクロのようにコードが書き変えて作っているわけではなく、関数が n への参照を保持しています。
1.1. 部分適用と文脈保存
add10のようなクロージャは、「部分適用」という使い方です。
複数の引数を持つ関数の一部の引数を先に固定して、残りの引数を受け取る新しい関数を作る手法です2。
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 だけ渡せばよく、文脈の管理が呼び出し側に漏れません。
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言語には普通の関数はありますが、呼び出し時に n を渡してもらう必要があります。
int add(int x, int n) {
return x + n;
}Code language: C++ (cpp)
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言語でのクロージャ実装
C言語でクロージャを作るには、構造体を定義します。
Closure構造体とAdderEnv構造体です。
typedef struct {
int (*func)(void *env, int x);
void *env;
} Closure;
typedef struct {
int n;
} AdderEnv;Code language: C++ (cpp)
クロージャそのものとAdder用の環境を保持します。
adderクロージャを作るには、環境構造体にメモリを割り当てて、add関数の関数ポインタを保持したClosureを生成します。
int add(void *env, int x) {
AdderEnv *e = (AdderEnv *)env;
return x + e->n;
}
Closure make_adder(int n) {
AdderEnv *env = malloc(sizeof(AdderEnv));
env->n = n;
Closure c;
c.func = add;
c.env = env;
return c;
}Code language: C++ (cpp)
使うには、このようにします。
#include <stdio.h>
#include <stdlib.h>
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を破棄するときには、内部のAdderEnvに割り当てたメモリを解放する必要があります。
Closure 構造体がクロージャ本体です。
| クロージャの要素 | Cでの実装 |
|---|---|
| 関数本体 | 関数ポインタ func |
| 変数環境 | 環境構造体 env |
| 環境の保持 | malloc でヒープに確保 |
| 自動的な環境管理 | なし。 |
手動で free が必要 |
make_adder(10) と make_adder(20) は同じ関数ポインタを使いながら、別々の AdderEnv を持ちます5。
Common Lispでクロージャが自然にやっていることを、Cでは構造体と明示的なメモリ管理で実現しています。
malloc が必要なのは、関数呼び出し後も n を残すためです。
通常の局所変数はスタックに置かれ、関数が終わると消えます。
ヒープに確保することで、make_adder が終わった後も環境が生き続けます6。
2.2. Adderオブジェクトを生成する実装との比較
Cで書くと、クロージャとオブジェクトが同じ形になることに気づきます。
C言語でオブジェクト指向を書くと、オブジェクトは「状態を持った構造体」と「その構造体を受け取る関数」の組み合わせになります。
typedef struct Adder Adder;
struct Adder {
int n;
int (*call)(Adder *self, int x);
void (*destroy)(Adder *self);
};Code language: C++ (cpp)
ここで Adder は、C++やJavaのような言語でいう「オブジェクト」に相当します。n が状態で、call と destroy がメソッドということになります。
int adder_call(Adder *self, int x) {
return x + self->n;
}
void adder_destroy(Adder *self) {
free(self);
}Code language: C++ (cpp)
make_adder をオブジェクト指向的に書くなら、最終的には Adder *make_adder(int n) がコンストラクタで、返ってくる Adder * が add10 オブジェクトです。
Adder *make_adder(int n) {
Adder *self = malloc(sizeof(Adder));
if (self == NULL) {
return NULL;
}
self->n = n;
self->call = adder_call;
self->destroy = adder_destroy;
return self;
}Code language: C++ (cpp)
クロージャ生成に対応する make_adder 関数は、C++やJavaでいうコンストラクタの役割をしています。
このように使います。
#include <stdio.h>
#include <stdlib.h>
int main(void) {
Adder *add10 = make_adder(10);
Adder *add20 = make_adder(20);
if (add10 == NULL || add20 == NULL) {
return 1;
}
printf("%d\n", add10->call(add10, 5)); /* 15 */
printf("%d\n", add20->call(add20, 5)); /* 25 */
add10->destroy(add10);
add20->destroy(add20);
return 0;
}Code language: C++ (cpp)
callメソッドを呼び出す時には、C++なら暗黙に this が渡されますが、Cでは明示的に self を渡します。
add10->call(add10, 5);Code language: C++ (cpp)
これは、概念的には次のような呼び出しです。
adder_call(add10, 5);Code language: C++ (cpp)
3. クロージャとオブジェクトの見方の違い
クロージャ版との違いは、見方の重心です。
どちらも実装上は「状態 + 関数」のデータ構造ですが、クロージャでは「関数が環境を持つ」と見て、オブジェクトでは「データが操作を持つ」と見ます。
クロージャ風なら、
Closure add10;
add10.func(add10.env, 5);Code language: C++ (cpp)
オブジェクト風なら、
Adder *add10;
add10->call(add10, 5);Code language: C++ (cpp)
形を見ると、かなり似ています。
この構造を対応させると次のようになります。
クロージャでは、env が状態です。
オブジェクトでは、self が状態を持つ本体です。
つまり、次の2つは実装レベルでは対応しています。
func(env, x)
method(self, x)Code language: C++ (cpp)
| 観点 | 素朴なクロージャ | 素朴なクラスオブジェクト |
|---|---|---|
| 基本構造 | 関数ポインタ + 環境 | 状態 + メソッド |
| Cでの形 | func + env | fields + function pointers |
| 状態の置き場 | env | struct のフィールド |
| 呼び出し | func(env, x) | method(self, x) |
| 暗黙に持つもの | 関数が環境を持つ | データが操作を持つ |
| 中心 | 実行する関数 | 操作されるデータ |
| 複数生成 | 同じ関数本体 + 別々の環境 | 同じメソッド + 別々の状態 |
違いは、何を主役として見るかです。
- クロージャでは、主役は関数です。
「この関数はn = 10という環境を覚えている」と見ます。 - オブジェクトでは、主役はデータです。
「このAdderオブジェクトはn = 10という状態を持ち、callという操作を持つ」と見ます。
同じ make_adder(10) でも、クロージャ風に見ると「10を覚えた関数を作る」です。
オブジェクト風に見ると「10を状態として持つ Adder オブジェクトを作る」です。
対応をもう少し抽象化すると、こうなります。
| Common Lispのクロージャ | Cのクロージャ風実装 | Cのオブジェクト風実装 |
|---|---|---|
| ラムダ式 | 関数ポインタ | メソッド関数 |
| レキシカル環境 | env 構造体 | self 構造体 |
| クロージャ生成 | make-adder | make_adder |
| 関数呼び出し | funcall | call(self, ...) |
| GCによる寿命管理 | 手動 free | destroy メソッド |
ちなみに、Cでは、それを明示的に構造体で作りましたが、Common Lispでは、次のように書くだけで、処理系が「関数本体」と「環境」を束ねます。
(defun make-adder (n)
#'(lambda (x)
(+ x n)))Code language: Lisp (lisp)
その結果、次の2つは同じ構造を別の語彙で説明していることになります。
| クロージャの語彙 | オブジェクトの語彙 |
|---|---|
| 環境 | フィールド |
| 関数本体 | メソッド |
| クロージャ生成 | コンストラクタ呼び出し |
| 閉じ込められた変数 | インスタンス変数 |
| 関数値 | オブジェクト |
funcall | メソッド呼び出し |
結論として、C言語で素朴に実装すると、クロージャもオブジェクトも「構造体に状態と関数ポインタを入れる」という形に落ちます。
Common Lispで言えば、クロージャはひとつの関数のために環境を閉じ込めます。
複数の操作を通じて同じ状態を扱いたいなら、Common Lisp Object SystemであるCLOSのほうが自然な設計になります7。
3.1. 言語の抽象レイヤーとして見る
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。
4. まとめ
クロージャを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