Lisp開発スレ
7年前に一度挫折した Lisp 開発を再開しました(もう再開して、ひと月以上たってるのに今更)
ポリシーは次のとおり
- Go言語で開発し、Go言語アプリケーションに組み込める小さな処理系とする
(GopherLua みたいな) -
Common LispISLisp と autolisp の最大公約数的な仕様とする
参考にしたページ
結構、リファレンスとしては良い。このページに記載の機能で未実装な機能:
- (format)
- 分数・複素数・number型(ただし、integerとfloatはある)
(first)(second)(third)(rest)(find)、(member)はあるが :test パラメータ未対応- 配列、ベクトル、ハッシュテーブル
- 構造体、クラス
- 多値代入
(eq)(eql) (イコールは (equal)(equalp) しかない。(equalp) は英大文字・小文字や/実数・整数を区別しないだけ)(incf)(decf)- (type-of)(describe)(deftype)
(when)(unless)(case)- (loop)
(do) - オプション引数・
キーワード引数・補助パラメータ(ただし &aux のかわりに autolisp と同様に / が使える)、多値返却 - 数学系関数
pi などの組み込み定数例外
目下の課題(1) (setf) を実装したのはよいが、作り方が標準とは違っていた
(setf) とは代入を行う万能関数で
gmnlisp> (defparameter m '(1 . 2))
m
gmnlisp> m
(1 . 2)
gmnlisp> (setf (car m) 3)
3
gmnlisp> m
(3 . 2)
のように、構造の中の値まで書き換えることが出来る。
Common Lisp では (setf (car X) Y)
は (replaca X Y)
いう別関数の置換するマクロとして実装されているらしい(replaca は consセルの car 部分を置換する関数)。
が、gmnlisp では (car) などを右辺値(値そのものとセッターのクロージャ)を返す関数として実装し、(setf) はそういう一部の関数のセッターを呼び出す関数として実装してしまった。
でも、まぁ、問題なく動くわけだし、これはこれでいいか
目下の課題(2)キーワード引数
これを実装しないと、ちゃんとした (with-open-file) が実装できない。これは関数の I/F に改修がいるなぁ
gmnlisp の append 、うっかり、引数のリストそのものに破壊的変更しちゃってたので、直した。
(let ((x '(1 2 3)))
(append x '(4 5 6))→ これで式の全体の値だけでなく、x そのものも (1 2 3 4 5 6) になってしまっていた。
Go言語の雰囲気でやってしまったヨ!
SBCLにて検証:
(let* ((part1 (list 1 2 3))
(part2 (list 4 5 6))
(part3 (list 7 8 9))
(all (append part1 part2 part3)))
(print all)
(setf (nth 1 all) 'aaa)
(print part1)
(setf (nth 4 all) 'bbb)
(print part2)
(setf (nth 7 all) 'ccc)
(print part3)
(print all)
(terpri)
)
$ sbcl --script append.lsp
(1 2 3 4 5 6 7 8 9)
(1 2 3)
(4 5 6)
(7 CCC 9)
(1 AAA 3 4 BBB 6 7 CCC 9)
なるほどー
gmnlisp のコミット 2068edc を動かしていて見つけた問題です。
現時点では gmnlisp はまだ試行錯誤していて仕様に曖昧な部分もあるということは承知しておりますので、あくまでも既存の Lisp 系言語の一般的な仕様に照らしておかしいと感じられる部分や、これもアリだけれど今の内に検討してはっきりさせた方が良いと思った箇所について記述します。
私自身は Scheme 派であり、 Common Lisp の系統にはそれほど明るいわけではないということもお断りしておきます。
トップレベルでのクオート
トップレベルでクオートを付けたリテラルを評価しようとするとエラーになります。
gmnlisp> 'foo
Unbound variable `'`
クオート記号は quote
の短縮表記ですので、この場合は (quote foo)
を評価したときと同じ結果が期待されます。
二重のクオート
トップレベル以外を含めて二重にクオートしたときの解釈がおかしいようです。
gmnlisp> (print ''foo)
print: Too many arguments
''foo
は (quote (quote foo))
の意味に解釈されるべきものですが、どうやら '
という名前のシンボルと foo
という変数のふたつに解釈されてこのエラーになっているように見えます。
真偽値
nil
が小文字で T
が大文字なのは不自然なのでどちらかに統一した方がよいように思います。
gmnlisp> (equal 1 2)
nil
gmnlisp> (equal 1 1)
T
大文字小文字
Common Lisp では識別子の大文字と小文字の区別がありますが、デフォルトではリーダが大文字に変換するという挙動によって表面上は区別がないかのような扱いになります。 ただし、この挙動は変更することもできます。
Common Lisp はリーダのカスタマイズが出来過ぎるので小さな Lisp でカスタマイズ性を真似するべきではないと思いますが、それを脇においても古い Lisp 系言語ではリーダが大文字・小文字を統一しつつ内部的には区別があるという挙動はよくあるものだったようです。
一方で、 Scheme では仕様改定の折に大文字・小文字を区別するように改められましたし、 Clojure でも区別します。 今時は大抵のプログラミング言語で大文字・小文字を区別するので区別することにしておかないと連携するときに困るのです。
現代的な感覚や実用性、アプリケーション組み込み用という立場からは大文字・小文字を区別するのが自然だと思いますが、 Common Lisp や AutoLisp を意識するなら検討する必要があるだろうという意味での背景の説明です。
with-open-file 実装
サポートしたオプション引数は
-
:direction :input
と:direction :output
-
:if-does-not-exist
(← 仕様をちゃんと説明している文書が以外とないが、ファイルが存在しなかった時、引数の値をファイルハンドル用変数に入るみたい)
だけ。普通はオープンエラーは、そのままエラーにしてキャッチするですかね…
それだ!
この autolisp 用の「未使用 / 未宣言の変数を検出するスクリプト」を gmnlisp で動作できるようにするのが一つの目標でした。で、(findfile) とか *error* とかを誤魔化すために以下のスクリプトから呼び出すようにしても、どうしても不適切な「未使用エラー」を出してしまう。
(defun findfile (fname) fname)
(defvar *error* nil)
(load "strict.lsp")
(strict "strict.lsp")
これ、当時、数日とりくんでどうしても分からなかったので、一旦諦めたのですが…
今日ひさしぶりに実行してみると、(princ) で引数の個数のエラーが出るくらいで、うまく動くようになってしまいました。
面倒なので、いまさら原因は調べませんが、おそらくはクォート関連の実装方法を変えたせいでしょうね
とほほのLISP入門の loop
の説明は大幅に省略されていますね。
Common Lisp の loop
は仕様の中で最も複雑な構文として有名で、無限ループのためだけの構文というわけではありません。 様々なループのパターンを扱える DSL です。 loop
は無ければ無くてもプログラミングには十分に足りるのですが、ちょっと複雑な場合をひとつの式に押し込めたいという場合に欲しくなるというのが loop
という構文の立場です。
なのであまり loop
の機能を省略しすぎると他のループ系構文に比べて特に便利というわけでもなくなるので存在価値が小さいですし、フルセットを用意するのはさすがにしんどいでしょうから、どのあたりで妥協するか難しいところですね。
いったん (loop) は仕様から外そうと思います。かわりに ISLisp 系のループ系構文をなんぼか実装する方向で
文字列表現について
- 最初は Go の string (UTF8)をそのまま使っていた
- その後、(setf (aref STRING INDEX) VALUE) の実現の都合、rune配列(UTF32)に変えた
しかしながら、Goのライブラリを使うたびに UTF8 変換をしなきゃいけないのは、効率が辛い。どこかの効率か標準仕様準拠のどこかを諦めなければいけない
お聞きしたリンク
-
Gauche - A Scheme Implementation
- Gauche は UTF8 の方を採用しているらしい。
- UTF8文字列と、UTF32文字列の両方を実装した。
- 内部的には UTF8String(=string) , UTF32String(=[]rune)
- デフォルトは type String = UTF8String だが、type String = UTF32String でもビルドできる
- UTF8String は READONLY で、ランダムアクセス不可(setf (elt "STRING" INDEX) VALUE) のようなことはできない
- (to-utf8 …) , (to-utf32 ...) で相互変換できる
UTF8String は (elt "STRING" INDEX) はできるが、計算量 O(n)
- (eq A B) … A と B はアドレスも含めて同値
- (eql A B) … A と B は値・型ともに同じ。コレクションの場合、要素が一致していても t にはならない(器が違うから?)
- ISLisp/SBCL では (eql (cons 1 2) (cons 1 2)) は nil。現行の gmnlisp では t [要修正]
- (equal A B) … A と B 、構成要素まで同じであれば「同じ」扱い。ただし、整数(2)と実数(2.0)は nil、英大文字・小文字も nil
- ISLisp/SBCL では (equal 2 2.0) , (equal "a" "A") ともに nil。gmnlisp では (equal 2 2.0) は t [要修正]、(equal "a" A") は nil
- (eqlualp A B) … ISLisp には存在しない。SBCL/gmnlisp では (equal 2 2.0) (equal "a" "A") ともに t
- (eql "A" "A") → nil (ISLisp , SBCLともに) 。ISLisp の規格上は処理系定義。gmnlisp では Go の仕様上、スライスは nil としか比較できないので、文字列については (equal) と同じ扱いにせざるをえない
ISLISP で (eql "A" "A")
の結果が処理系定義と書かれているのは文字列リテラルの場合の動作であって文字列一般の動作ではないと解釈するべきです。
それらを(オブジェクトの変更なしに)区別する操作がなく,かつ,一つのオブジェクトの変更がもう一つのオブジェクトを同じように変更する場合に同一とする。
というのが eq
と eql
の挙動です。 数値と文字の場合を除いて eq
と eql
は同じ結果であるべきです。
プログラムテキスト中にリテラルとして含まれる文字列は,変更不可能なオブジェクトとする。
とあるので、リテラルとしての文字列に限ってはオブジェクトを変更した場合という条件が成立しません。 前提条件が成立しないので結果を決められず、処理系にまかせるということです。
これは C で "A"=="A"
としたときに真になる場合があるというのと同じようにリテラルの配置に関しての処理系の裁量を認めていると考えられ、等価性に関しての裁量ではありません。
文字と数値が特別扱いなのは、ポインタより小さなサイズで表現可能な小さな値はヒープアロケーションしてそれへのポインタで表すということをせずに直接的にそこに埋め込むというような最適化をする実装が一般的だからです。 (大きな数値のときにはヒープアロケーションしてシームレスに切り替える。) Ruby とかでもやってますね。
実装の都合が言語仕様に現れるのは不格好なんですが、この特別扱いのために同値判定と同一判定の二種類ではなく間にもうひとつの等価性判定が用意されているんです。
地道に ISLisp の関数を一つ一つ実装してゆくのも飽きてきたし、このあたりで何か実用的なアプリを書いてみたいなということで「S式で Makefile を書く make」を作ってみた。
ビルドルールを S 式で与えるということは、動的にルールを生成することもできるということで、これは結構便利かもしれない。C言語のビルドルールの自動生成もできた。
SQL-Bless も特にすることがなくなったので、ここ最近は久しぶりに gmnlisp のコードを書いている。クラスと包括関数をだいたい実装できたが、包括関数の :rest (可変長引数)がまだ実装できていないので、コンストラクタに相当する initialize-object の呼び出しがまだできない
(with-handler ハンドラー関数 form*)
って、大域脱出の機能はなくて、そういうのはハンドラー関数の方で自分でやれ…っていうスタンスなのか
(「この機能には、これがあるはず」と思い込むと、見つからないとき、しっくりこず悩みまくるということが自分にはよくあることが分かった)
ハンドラー関数側では
- 自前で帯域脱出関連の関数を呼び出す
- (continue-condition) を呼び出す
どちらかをやらないといけない(普通に正常終了で終わった場合の動作は未定義)
ハンドラー関数の登録場所、最初は静的スコープのスタックに置いていたけど、動的スコープでないとエラーハンドラー関数を外においたときに機能しないことに気付いた。
(defun simple-error-handler (c)
(apply format (error-output) (simple-error-format-string c) (simple-error-format-arguments c))
(throw 'c nil))
(catch 'c
(with-handler #'simple-error-handler
(error1 "my simple error AHAHA~%")
(format (error-output) "no exception (1))~%"))
(format (error-output) "no exception(2)~%")
)
以前のハンドラー関数の呼び方が間違えていた。
- (旧・誤)エラーで戻ってきたところで呼び出し
- リカバリ後に継続ができない
- 内部処理として、普通の error でエラーを受け取っても対応できる
- (新・正)エラーが発生したところで呼び出し
- 継続する時は呼び出したところにそのまま戻ればよい
- 内部処理で、error を return しているところだらけ
今のところ、両者ハイブリッドで
- 普通にGoのコード中でエラーが発生した場合は、error を return のリレーして受け取った
(with-handler)
関数がハンドラーを呼び出す(継続不可) -
(signal-condition)
を使っているところは、その場でハンドラーを呼び出す(継続可能)
最終的には後者の方で統一したい(が、コードのイメージがちょっと見えづらい)
gmnlisp の最新版を smake に適用しているが、(apply #'make … )
がエラーになって動作しなくなった。これはどうやら動かないのが正解らしい。
- ISLisp の規格によると
#'
=(function)
の仕様として、マクロ・特殊形式の式が与えられたとき、結果は未定義とされている -
(apply F OBJ* LIST)
では LIST だけ評価するが、マクロ・特殊形式では評価する/しないを自力で判断するので、既に評価済みの LIST をそのまま与えることができない。二重評価された時に二回目の評価を無効化するようなコンテナに LIST の各要素をくるんで、F に与えることになるが、マクロ・特殊形式ではそれをうまく扱うことができない(逆に普通の関数だと大丈夫)
smake の対応として、(apply #'make …)
に相当する (make* …)
みたいなのを作ることにする
ISLisp の検証プログラムがようやく動き始めた。
まず、eval 関数が存在することが要件としてあったが、それがどこにも明記されていなかった(eval 関数は ISLisp の規格書に載っていない)。
eval 関数を実装して走らせたもののエラーだらけ。
出ている構文自体手でタイプするとちゃんと動くので、検証プログラムの仕組みの何かがちゃんと機能していなかった。defglobal 文でグローバル変数が定義されるはずが、定義されていない。
プログラムを追っていると、検証プログラムの「(setq form `(eval ',form)))」というコードをうまく処理できていなかった。つまり、マクロ用の演算子: , (カンマ:unquote とかいうらしい) を、' (シングルクォート、これは知ってる人も多い quote)、の直後に置くようなケースに対応できていなかった。
これのせいで、評価されなければいけないリストが評価できていなかったという問題があった。中のロジックを見ると定義系の命令を処理する時だけ、quote → unquote が使われていたので、(defglobal) だけちゃんと動いていなかったようだ。
言うちゃなんだけど、かなりヘンタイ的な使い方だよなぁ
ISLisp検証プログラムのエラーメッセージ
NG: ((lambda (x) (+ x x)) 4) -> #<Error> <domain-error> [8]
これ、((lambda (x) (+ x x)) 4)
を評価した結果がエラーになったけど、期待されるのは 8 だから NG ということらしい。
これ、おかしくね?エラーにするのが正しいだろ?lambda の戻り値って関数オブジェクトだから、これを使って関数を呼び出すの funcall を使わないとダメだろ。これを緩めるのは簡単だけど、なんか納得いかん
既存の ISLisp 処理系は funcall なしに呼び出せるようになってる。
$ ISLisp.exe
> ISLisp Version 0.80 (1999/02/25)
>
ISLisp>((lambda (x) (+ x x)) 4)
8
ISLisp>(funcall (lambda (x) (+ x x)) 4)
8
$ iris.exe
Iris ISLisp Interpreter Commit HEAD on go1.22.3
Copyright 2017 islisp-dev All Rights Reserved.
>>> ((lambda (x) (+ x x)) 4)
8
>>> (funcall (lambda (x) (+ x x)) 4)
8
まぁ、変数と関数で名前空間が違うのはシンボルの解釈の時だけということなんかな
評価規則によれば「オペレータがラムダ式のとき」という場合分けがあるようです。 関数を返す式一般に対してではありません。
;; こういうのはエラー
(defun self (x) x)
((self (lambda (x) (+ x x))) 4)
なるほど、JIS の規格書の方ではどうなっているんだろうと探してみたら、そちらにも確かに同様の記述[1]がありました(コードが少ないセクションだったので、あんまり読んでいませんでした)。ありがとうございます。
-
(自分用メモ)https://kikakurui.com/x3/X3012-1998-01.html > 4.6 評価モデル (p.16 ) ↩︎
$ gmnlisp
gmnlisp v0.7.0-111-g1be547f-windows-amd64 by go1.22.5
gmnlisp> (eql (parse-number "#x1234567890abcdefABCDEF") 22007822917795467892608495)
can not parse number (22007822917795467892608495: strconv.ParseInt: parsing "22007822917795467892608495": value out of range)
gmnlisp>
うーん、どうしたもんかな(一応、検証項目)。たぶん int64 越えてる
ISLisp検証プログラム (catch 'tp-data-error …)
がどこにも書かれていないので、tp-data-error に関する NG が出た場合、即検証プログラム自体がエラー終了してしまう不具合がある。
(他のエラーはちゃんと catch している)
これは tp-data-error に関する NG を全てとらないと次へ進めないということだね…
ISLispの検証プログラムがようやく最後のテスト項目までクラッシュやハングなしに走るようになった。成績は TP Result: OK = 7889, NG = 8522
。先は長いなー。でも、ようやく進捗が明確になった。
区切りとしてリリース:
>Release v0.7.2 · hymkor/gmnlisp https://github.com/hymkor/gmnlisp/releases/tag/v0.7.2
あるNG項目をOK にすべく修正すると、他のOK項目がNGになったりするので「こまったなー」という顔をしている。
具体的にいうと、t
とか nil
って boolean であると同時に symbol であるらしくて、一見 symbol しか書いてはいけない場所とかに nil
とかを書いても実は OK だったりするパターンがたまにあったりする(具体的には defun の関数名のトコ)
で、がんばって t
とか nil
をシンボル派生になるようにして、シンボルを要求された時に t
とか nil
があっても <domain-error>
にならないようにすると、案の定別のところが NG になったと…
理由は想像ついてて、ISLisp って、データと関数で名前空間が違っていて、t
や nil
はデータの名前空間では使用済みでアウト(定数の上書きは禁止されている)だけど、関数の名前空間では空きだからセーフってことなんでしょうね。
そう、自作Lisp、上書きを禁止できる定数(defconstant)がまだ実装されてない…