見出し画像

Retro-gaming and so on

fluid-letとスペシャル変数

Lispはカッコが多いから嫌われる。
しかしながら、カッコの存在のお陰で、ブロックと言うものがLispプログラム上のあらゆる場所で明確にはなっている。
ブロック、そしてそれが形成するレキシカル・スコープに対して、Lisp程明快性を持った言語は他にはないんじゃないか。

反面、Lispではレキシカル・スコープがあまりにも明確性を持って存在するが為、概念的、とか、その動作に付いて他の言語じゃ比較出来ないような、要するに、一見すると説明が難しくなるような現象がある。
曖昧さが無いが為に逆に理解が難しくなる、と言う事だな。

たとえば、Racketで次のようなプログラムを書いてみよう。

(define counter 0)

(define (bump-counter)
 (set! counter (add1 counter))
   counter)

これは独習Scheme三週間と言うサイトに掲載されている例だが、流し読みする程度じゃ大して悩みそうにもない。しかし、実は理解するのがかなり厄介なプログラムなんだ。
そもそもこれを理解する、ってのは他の言語を鑑みるとかなり難易度が高い。
問題は、関数bump-counterだ。
関数bump-counterの本体内部では大域変数counterが使われている。そこまでは誰でも分かるだろう。
じゃあ、このbump-counterを局所変数として定義されたcounterを持つブロック内で呼び出した時、一体このbump-counterはどういう挙動をするんだろうか、って辺りが問題なのである。

(define counter 0)

(define (bump-counter)
 (set! counter (add1 counter))
 counter)

(let ((counter 99))
 (display (bump-counter)) (newline)
 (display (bump-counter)) (newline)
 (display (bump-counter)) (newline))

そもそもこんなプログラムは通常のプログラミング言語だとあり得ないプログラムだ。なんせ、特殊な構文(iffor)を引き連れてブロック生成するわけでもなく、いきなり「局所変数countだけで形成された」ブロックでのプログラムを書くのは通常のプログラミング言語だと不可能だから、だ。
そんな記述法がフツーのプログラミング言語では「あり得ない」以上、まずここだけで理解する為の難易度は跳ね上がるだろう。
次に難しいのは、letが形成しているブロック内で呼び出されたbump-counter、その本体にある筈の変数counterは一体何を参照するんだ?って問題だ。
レキシカルスコープの原則で考えてみれば、letで形作られたローカル変数のcounterが大域変数counterをシャドウイング、つまり、覆い隠してしまって、そっちを参照するんじゃないか、と思うだろう。
しかし、予想に違えて、実はこういう実行結果になる。

1
2
3
>

そう、関数bump-counterはローカル変数counterを無視して大域変数counterを参照する。「え?なんで?」と驚くような結果だろう。
実際、このプログラムを走らせたあとは、大域変数counterの値は破壊的変更を受けて変わってしまう。

> counter
3
>

はてさて、これは一体どういう理屈でこうなるのか。
平たく言うと、Lispでは関数は関数が呼び出された環境を参照しない。関数は自らが定義された環境を参照するのだ。
この場合、関数bump-counterは大域変数counterが存在する環境で大域変数counterを参照するように設計されている。つまり、「ここで使ってる変数counterは大域変数ですよ」と言う情報も合わせて閉じ込めてるんだ。
だから関数bump-counterはどんな環境下で呼び出されようとも、その重要情報を絶対忘れない。
こういう機能をレキシカル・クロージャと呼ぶ。シンプルな関数を書いてるつもりでもLispの関数は自らが定義された環境情報、と言う複雑な情報でさえ「自動で」保存・管理するようになってるわけだ(※1)。

一言言っておくと、こういうプログラム(つまり、本体内でいきなり大域変数を使うようなプログラム)は本来書くべきじゃない。仮に書いたとしても、大域変数は大域変数だと明示する(つまり*を使う耳あて法による命名法)事で、この例題を見た時のような混乱はかなり避けられるだろう。通常、大域変数とローカル変数を「同名」にする必要なんざまずねぇしな。
独習Scheme三週間では、その「まずないケース」を敢えてやることで、Lispの関数、及びレキシカル・クロージャと言う「機能」を表面化させたわけだが、こういう書き方はホンマやるべきではない。混乱するのが目に見えてハッキリしてるからだ。

さて、Racketから目を移すと、他のLispだとますます混乱するような事がある。
RacketなんかのScheme系言語だと、大域変数も関数もdefineを用いて定義する。何故なら名前空間が1つしかないし、わざわざ大域変数定義専用の構文なんざ用意する必要がないからだ。
一方、ANSI Common Lispなんかは名前空間は大域変数用と関数用に分かれている。したがって、大域変数定義は大域変数定義用の構文を持ち、関数定義には関数定義用の構文を持っている。Scheme系言語のようにdefineだけを使う、と言うわけにゃあイカんのだ。
一般に、ANSI Common Lispの大域変数定義用構文はdefvarと言う構文だと思われている。これは、Emacs Lispなんかの旧いLispでも持っている由緒正しい構文だ。
しかし、これは正確ではない。大域変数定義用構文じゃなくってスペシャル変数定義用構文なんだ。
ここでまた「スペシャル変数」なんつー、他のプログラミング言語じゃお目にかからない用語が出てきて混乱に叩き込まれる。一体なんだ、スペシャル変数とは。
上のRacketの例に合わせてみて、たとえばこんなプログラムを書いてみる。

(defvar counter 0)

(defun bump-counter ()
 (incf counter))

(let ((counter 99))
 (princ (bump-counter)) (terpri)
 (princ (bump-counter)) (terpri)
 (princ (bump-counter)) (terpri))

ところが、こいつを実行してみるとこんな結果が表示される。

100
101
102

あれ?Racketと結果が違う。
なんかletで束縛されたcounterを参照してるように見える。
実際、リスナーで大域変数に見えるcounterを確かめてみると、全く値が変わってなく、letで束縛したcounterを結果参照してないか。

CL-USER> counter
0
CL-USER>

と言う事はANSI Common Lispにはクロージャがないのか。
そういう結論にたどり着きそうになるが、んなこたぁない。ANSI Common Lispにもクロージャはちゃんとある。
ある筈なのに、letで束縛されたcounterの環境下ではbump-counterは大域変数のcounterを参照せずに、ローカル変数のcounterを参照しているように見えるのである。
これはRacketの目から見ると相当不可思議な挙動だ。そして平たく言うと、Racketの大域変数定義の為のdefineとANSI Common Lispのdefvarの動作が違う、と言うことが原因なんだ。
言い換えるとRacketではスコーピングルールは性的静的スコープで統一されている。一方、ANSI Common Lispの場合、ビミョーに歴史的なダイナミックスコーピングが混ざってる。そして、ANSI Common Lispのdefvarが作り出す変数は、ダイナミックスコープな変数なんだ。従って、関数bump-counterが本体に閉じ込めた大域変数、counterは、ダイナミックなモノであり、Racketでのbump-counter内部のcounterとは性質が違う。ANSI Common Lisp版だと一番近場にあるcounterを探しに行き、letで束縛されたcounterを発見した時点で「満足するような」性質の変数をdefvarは作り出すのである。

正直言うと、ANSI Common Lispでの「大域変数」とRacketでの「大域変数」のどちらが「正しい」のか、と言うとRacketの方が正しい、とは思う。ANSI Common Lispから以前の「ダイナミックスコーピング」と言うのはバグの元と言えばバグの元だからだ。しかも変数の「種類」によってスコーピングのルールが変わる、ってのはちと頂けないだろう。もっともANSI Common Lispが何故に強力なのか、と言うのはこのテの「抜け道」が豊富にあるからだ、と言う言い方もできるけどね。一方、Racketを含むScheme系言語は厳格で融通が利かない、と言う言い方もできるだろう。

ところで、世の中には、Schemeでこのダイナミックスコーピングをエミュレート出来ないか、と考える人たちもいるようだ。
言語の根幹にある、「変数の種類」は変更が不可能だ。代わりにダイナミックスコーピングを一種「許してるように見える」letの変種を定義すりゃあエエんちゃうの?と考えたらしい。
考え方はこうだ。例えば、上のRacket版bump-counterはレキシカル・クロージャとして大域変数counterを常に参照してる。そこで、letの変種を使用した際に、大域変数counterの値をletの変種で束縛した値でこっそり書き換えて、本体が実行された後に元の値に密かに戻せばエエんちゃうの?と言うのがそのアイディアだ。
このletの変種をfluid-letと呼ぶ。
それはマクロでこう定義する。

(define-syntax fluid-let
 (syntax-rules ()
  ((_ ((x e) ...) body ...)
   (let ((old-x x) ...)
    (set! x e) ...
    (let ((result (begin body ...)))
     (set! x old-x) ...
     result)))))

そうすれば、ANSI Common Lisp版の動作をエミュレートすることが可能となる。

(define counter 0)

(define (bump-counter)
 (set! counter (add1 counter))
 counter)

(fluid-let ((counter 99))
 (display (bump-counter)) (newline)
 (display (bump-counter)) (newline)
 (display (bump-counter)) (newline))

実行結果は次のようになる。

100
101
102
>

そして大域変数のcounterは「元の値」を保持してる。

> counter
0
>

実際は、先にも書いた通り、fluid-letマクロは、レキシカル・クロージャが参照してる大域変数counterの値を一旦letで保存し、破壊的変更で、99に書き換えた後本体(body ...)を実行、そしてまた大域変数counterの値を保存してた元々の値で、破壊的変更で書き換えてるのである。
そうすると、一見、ANSI Common Lispのダイナミックスコーピングが「成立」してるように見えるんだな。
実際は、やっぱりダイナミックスコーピングを許してるわけじゃないんだけど、「一見そう見える」ようなマクロを作ってる、ってのがこのfluid-letマクロのキモなわけだ。

いや、本当の事を言うと、fluid-letに頼らなきゃいけないようなプログラムはやっぱ書くべきではないと思ってる。ただし、書き換えに次ぐ書き換え、と言う煩雑なプロセスを簡単に成立させてしまう「マクロの例」としては面白い例だよな、と改めて思って紹介したまで、である。
そうでもないと、なかなか面白いマクロの例とか思いつかないんでね。

以上。

※1: 意外な事に、gccやclang等のコンパイラを使ってC言語でプログラミングすると、Racketと全く同じ解が得られる。

#include <stdio.h>
#include <stdlib.h>

int counter = 0;

int bump_counter(void) {
 counter++;
 return counter;
}

int main(void) {
 int counter = 99;
 printf("%d\n", bump_counter());
 printf("%d\n", bump_counter());
 printf("%d\n", bump_counter());
 printf("%d\n", counter);
 return 0;
}

1つの理由として、スクリプト系言語と違い、main関数と言う実行部があり、ここは関数だけれども関数ではなく、事実上Racketのような「ローカル変数が引き連れるブロック」を形成してるようなものであること。
そしてもう1つは面白い事に、clangやgccのような処理系で分かる事は、ラムダ式は存在しないが、レキシカルクロージャに限りなく近いものを持っている、と言う事だ。
言い換えると、Algolが作り出した「レキシカルスコープ」と言う機能を限りなく正しく実装している、と言う事である(マイクロソフトの実装でどうなのか、は調べてないから分からないが)。
一方、残念なのはPascalだ。これもAlgolの正統なサブセットと思われているが、この問題に関してはgcc/clangのスペックに届いていない。
仕様上なのか、あるいは元々Delphiがそうした処理系なのか、は知らんが、少なくともFree Pascalの関数は、自らが定義された環境を反映していない。

program test;
var counter : integer = 0;
function bump_counter(): integer;
begin
 counter := counter + 1;
 bump_counter := counter;
end;
begin
 counter := 99;     (* ここが怪しくて、これじゃ単に大域変数を書き換えている *)
 writeln(bump_counter);
 writeln(bump_counter);
 writeln(bump_counter);
 writeln(counter);
end.

少なくとも、FreePascalでは、最後のbegin(プログラム実行部でC言語のmain関数にあたる)内で、そこで大域変数counterと全く別のローカル変数counterが宣言出来ないみたいで、Algol由来、といいつつ、少なくともこの実験ではC言語のフリーコンパイラ陣に負けている。
なお、一般に、スクリプト言語陣はやはりLispのスペックには機能的には到達してない例が殆どだろう。
  • Xでシェアする
  • Facebookでシェアする
  • はてなブックマークに追加する
  • LINEでシェアする

最近の「プログラミング」カテゴリーもっと見る

最近の記事
バックナンバー
人気記事