見出し画像

Retro-gaming and so on

RE: プログラミング日記 2021/12/21〜

星田さんの記事に対するコメント。

とりあえず打ち間違いなどの訂正をして(game-repl)を実行してみたが、、エラーでコマンドが入らず。
 今日で決着だと思ったんだがな〜

うん。
実のこと言うと「どの辺で詰まりそうか」ってのはある程度予想してました。
っつーか、「この辺で詰まるぞ〜」って予告してたつもりだったんだけど、こう言うのって実際詰まるまで意味が分からんのだよな(笑)。
取り敢えず、前紹介したコードは「日本語で遊べる」と言う日本語版だったんだけど、今回はLand of Lispの「魔法使いの家」の単純なRacket版の全コードを紹介する。
そしてまずはそれと星田さん自身が書いたコードを見比べてもらいたい。

多分殆どクリアしてんだよ。細かい違いがあっても、多分殆どOKでしょう。
違うのはgame-printとそのエンジン、tweak-textの二つだと思う。
よってちょっとここの詳細に入っていこう。

まずgame-printから。
日記にも書いてあったと思うんだけど、多分Schemeの方に、曲がりなりにも「リリカル☆Lisp」で先に慣れてる人には「異様な関数名の羅列」と言うのにまずはビックリすると思う。coerceとかな。大体、なんて読むっちゅーねん、とか。 => コアース、みたいになる。意味は「強制」。
んで何度も書いてるけど、ANSI Common Lispは「総称」指向だ。つまり、「データ毎に似たような機能の関数を定義する」のではなくって、「同じような関数なら一つに纏めちまえ」ってのが設計思想になってる。coerceも総称的な設計で、結果一つでANSI Common Lispに搭載されたシーケンス系のデータ型をバンバンと任意の型へと変換しちまう。
一方、Schemeは原理主義だ。手続きはデータ型に従って設計されるべき、と言う発想で設計されてるので総称的な機能はほぼない、って言って良い(※1)。
ま、しかしcoerceに対しては対応表を作っておいたんで、何とかなるだろう。
むしろ、ここでの問題は、string-trimの方だ。
こいつの方が遥かに厄介なのだ。

まずtrimとは?
英語では「刈り込む」事を意味してる。
これはSchemeに限らないんだけど、文字列操作上で一般に、ある文字列の端(両端なのか左端なのか右端なのかは場合による)から余計な文字を省く場合に使う言い回しだったりする。
結局、他の言語でもstring-trimとかtrimって言い回しは見かける可能性がある、って事だ。

良くあるケースだと、例えば次のような両端にスペースがある文字列があったとして。

" ふたりだけのセレモニー。。。\nおとなは そのまま よ\nこどもは Bボタンを おしてね "

両側から余計だと思われるスペースを取り除く事をtrimと称するのである。

"ふたりだけのセレモニー。。。\nおとなは そのまま よ\nこどもは Bボタンを おしてね"

まぁ、大体、空白文字はそのままだと邪魔なモンなんだ。Pythonを除いて、な(笑)。
問題は、空白文字以外の特定の文字が邪魔な場合、である。こういう場合はどうするのか。
実際はこういうのはあんま起きない、言わばレアケースなんだけど、そのレアケースが今回起こってる事である。

さて、game-printの内部動作は、まずはリストを受け取りリストをそのまま文字列に変換する。まずその役目をしてるのがANSI Common Lispではprin1-to-string、Racketではformatである。

> (format "~a" '(ゆうべはお楽しみでしたね。))
"(ゆうべはお楽しみでしたね。)"
>

これにより、リストはそのままで文字列になるんだけど、今trimしたい「余計なモノ」と言うのは(と)なのである。カッコとコッカ(※2)さえ取り除けば「フツーの文字列として処理が出来る。

んでここからややこしいトコ。

まずANSI Common Lispと違ってSchemeにはビルトインのstring-trimは無い。
しかしRacketにはstring-trimが一応はある(※3)。
Racketのstring-trimも原則的には文字列の端にある空白文字列を削除するための関数だ。

> (string-trim " ナンシーから緊急連絡 ")
"ナンシーから緊急連絡"
>

これがデフォルトの「引数が一つしかないパターンの場合」なんだけど、第二引数にパターンを指定する事によって、「空白文字以外」でも削除可能となる。
ただし、その指定方式がメンド臭いんだよ。・・・そう、性器正規表現が必要となるわけ(※4)。
上のソースコードは正規表現で指定した方法で書いている。

> (string-trim "(ニューヨークも沈黙しました!)" #px"\\(+|\\)+|\\ +")
"ニューヨークも沈黙しました!"
>

ここでは長くなるので説明しないけど、#px"\\(+|\\)+|\\ +"ってのが正規表現による「(または)または空白」の指定である。Racketの正規表現ではまたはは"|"と言う棒で指定する。
まぁ、でも正規表現はそれだけで本一冊になっちまうくらいややこしく、なんだかなぁ、てのは正しいのだ(※5)。

もう一つはSRFI-13SRFI-14の力を借りる事である。

(require srfi/13 srfi/14)

SRFI-13には、左側を刈り込むstring-trim、右側を刈り込むstring-trim-right、両側を刈り込むstrng-trim-bothと3つ関数が用意されている。

> (string-trim " これくらいなら楽勝ね\nじゃんじゃんばりばりよ! ")
"これくらいなら楽勝ね\nじゃんじゃんばりばりよ! "
> (string-trim-right " これくらいなら楽勝ね\nじゃんじゃんばりばりよ! ")
" これくらいなら楽勝ね\nじゃんじゃんばりばりよ!"
> (string-trim-both " これくらいなら楽勝ね\nじゃんじゃんばりばりよ! ")
"これくらいなら楽勝ね\nじゃんじゃんばりばりよ!"
>

当然、今回は3つ目のstring-trim-bothを使えば良い。
そしてどの関数もデフォルトではやっぱ「空白文字削除」なんだけど、第2引数で「どんな文字セットを対象とするか」指定することが出来る。
そう、ここで「文字セット」って概念が新しく出てくるんだけど、これがSRFI-14が提供するモノである。
基本的にはSRFI-13のstring-trim系の関数は、SRFI-14で定義されているchar-set:whitespaceと呼ばれる「空白文字集合に引っかかるものだけ」刈り込もうとする。
そこで、方策としては

  1. char-set:whitespaceと言う空白文字集合にカッコとコッカと言う文字を加える。
  2. カッコとコッカとスペースだけ、の新しい文字集合を定義する。
の2つが考えられる。
ぶっちゃけどっちでも良い。
例えば1. を選ぶなら、SRFI-14のchar-set-adjoinと言う関数を用いて、

> (char-set-adjoin char-set:whitespace #\( #\))
#<char-set>
>

とすればいいわけだから、結果、

> (string-trim-both "(歌って踊って音速も越える超音速アイドルまおで〜す)" (char-set-adjoin char-set:whitespace #\( #\)))
"歌って踊って音速も越える超音速アイドルまおで〜す"
>

とする、「既存の文字セットを利用する」やり方。
2.は同じくSRFI-14のchar-setと言う文字セット定義関数を使うやり方。

> (char-set #\( #\) #\space)
#<char-set>
>

すなわち、

> (string-trim-both "(ムネン アトヲタノム)" (char-set #\( #\) #\space))
"ムネン アトヲタノム"
>

繰り返すけど、最初に提示したソースコードではRacket組み込みのstring-trim性器正規表現でやってみたけど、多分SRFI-13+SRFI-14のコンビの方が分かりやすいんじゃないか、って思う。文字セット定義->関数に適用、って流れも一貫してるからね。
まぁ、どっちにせよ、好きにすりゃいいと思います。

さて、string-trimでカッコとコッカを刈り込まれた文字列は、ANSI Common Lispではcoerceに手渡されてリストに変換。Racketだとstring->listに手渡されてリストに変換される。
それが次のtweak-text関数に手渡されてLispお得意のリスト処理を行われるわけなんだけど。
ちとここで、Land of Lispに紹介されているtweak-text関数を見てみよう。

(defun tweak-text (lst caps lit)
 (when lst
  (let ((item (car lst))
    (rest (cdr lst)))
   (cond ((eql item #\space) (cons item (tweak-text rest caps lit)))
     ((member item '(#\! #\? #\.)) (cons item (tweak-text rest t lit)))
     ((eql item #\") (tweak-text rest caps (not lit)))
     (lit (cons item (tweak-text rest nil lit)))
     (caps (cons (char-upcase item) (tweak-text rest nil lit)))
     (t (cons (char-downcase item) (tweak-text rest nil nil)))))))

結論から言うと、この関数がRacket/Schemeに移植する際に「気をつけて移植しないといけない」関数になる。
星田さんは、例えばここで使われてる「nil」が偽値だと解読したようだ。そう、ここのnilは空リストを意図していない。Racket/Schemeだと#t#fを使わなきゃなんない部分なんだよな(いわゆる「フラグ」である)。
しかし、その他にもまだ問題があるのだ
1つ目。この関数は再帰関数である。しかし末尾再帰じゃないんだ。
ANSI Common Lispは仕様上、末尾再帰最適化を要求しない(※6)。
だからこれでいいってばいいの。
しかし、Schemeは末尾再帰最適化を要求する仕様である。そしてRacketは末尾再帰最適化をする実装である。
従って、named-let(名前付きlet)で末尾再帰に書き換えるべき課題になってるんだ。

;; 名前付きletが苦手ならまずはそのまま末尾再帰にしてみても良い
(define (tweak-text lst caps lit acc)
 (when lst
  (let ((item (car lst))
    (rest (cdr lst)))
   ; item は文字と比較されるだけなので、char=?の方が速い
   (cond ((char=? item #\space) (tweak-text rest caps lit (cons item acc)))
     ; eqv? を使っての member がSchemeでの memv
     ((memv item '(#\! #\? #\.)) (tweak-text rest #t lit (cons item acc)))
     ((char=? item #\") (tweak-text rest caps (not lit) acc))
     (lit (tweak-text rest #f lit (cons item acc)))
     (caps (tweak-text rest #f lit (cons (char-upcase item))))
     (else (tweak-text rest #f #f (cons (char-downcase item))))))))

ここで名前付きletによる末尾再帰に入る前に形式的な末尾再帰へと変換しておく。
なんかロジック的には良さそう、なんだが、実はまだエラーを含むんだ。
っつーか、末尾再帰に変えなくても、ANSI Common Lisp関数をRacketの関数に置き換えるだけでエラーになる、って言う不可思議な挙動を目にすると思う。
実はその原因は次の2つだ。

  1. ANSI Common Lispの原作だと(when lst ...lstが空リストになった時計算が終了するようになってるが、それはANSI Common Lispでは空リストが偽だから。一方、Racket/Schemeでは#fが唯一の偽であって、空リストは真である。従って(when lst ...と言う記述法はRacket/Schemeでは終了条件に成りえない(この表記法を真似たければRacket/Schemeでは(when (not (null? lst)) ...にすべきなんだけど、冗長かも?あるいは(unless (null? lst) ... の方がbetterか?)。
  2. ANSI Common Lispでは(car nil)(cdr nil)はエラーではない。一方、Racket/Schemeでは(car '())(cdr '())はエラーとなる。
特に再帰が上手く行っても、2番のように、ANSI Common Lispでは停止条件にもならないのが、Racket/Schemeだと空リストのcarcdrを取ろうとしてエラーになっちまう、ってのは、実は「あるある」である。
うん、この辺が実は一番ANSI Common LispとRacket/Schemeのコードを移植する際に皆一回はハマるトコなんだよな。
結果、上のコードの関数名を全部名前付きletの名前loopで置き換えて、その上から元々の関数名tweak-textでラップしていく、と言う方法を取って、慎重に書き換えていくと次のようになる。

(define (tweak-text lst caps lit)
 (let loop ((lst lst) (caps caps) (lit lit) (acc '()))
  (if (null? lst)
   (reverse acc)
   (let ((item (car lst))
     (rst (cdr lst)))
    (cond ((char=? item #\space) (loop rst caps lit (cons item acc))) 
      ((memv item '(#\! #\? #\.)) (loop rst #t lit (cons item acc)))
      ((char=? item #\") (loop rst caps (not lit) acc))
      (lit (loop rst #f lit (cons item acc)))
      (caps (loop rst #f lit (cons (char-upcase item) acc)))
      (else (loop rst #f #f (cons (char-downcase item) acc))))))))

まず(when lst ...っつーのは失敗した時に偽値を返すんだけど、それがANSI Common Lispだとnil、つまり空リストになるわけだ。
実は元のコードだとそれも再帰に利用して、言い換えると、再帰の底が空リストになるのが確実なんで、再帰でそれにconsしていく、って構造を作り上げてるわけだな。
なかなかアタマがいいっつーかハックなんだよ(笑)。
ただし、それはRacket/Schemeにはそのまま持ってこれない。大体Schemeのwhenunless基本的には戻り値が何だか規定されてない、ってのがフツーなんで、ANSI Common Lispのコードをそのまま持ってくるには不安なのだ。
つまり、Racket/Schemeでは正攻法で、

  1. lstが空かどうか調べる。
  2. lstが空の場合、アキュムレータで形成されたリストを逆順にして返す。
  3. そうじゃなければ末尾再帰する。
と言う大枠に従った方が良い、って事。
そして3番の時点で初めてANSI Common Lispの再帰コードからの翻訳を書いていった方が良い、って事になります。

以上(※7)。

※1:Schemeである総称系の関数は述語でeqv? equal?くらいだろう。
これらはそれぞれ、明らかにeq?+他の述語、と言うSchemeでは珍しい総称的な設計になっている(もっともこれらを分離したらかなりややこしくなるのは事実である)。

※2: もちろん正式名称ではないが、(をカッコ、)をコッカと日本人Lisperは称する事が多い。一種の洒落ではある。

※3: ややこしい事に同名関数でも引数の順序が違う。
ANSI Common Lispのstring-trim

(string-trim 削除したい文字セット 文字列)

だがRacketは逆に

(string-trim 文字列 削除したい文字セット)

になっている。

※4: 元々、Lispはそんなに正規表現には執着してない。他の言語と違ってリストを自在に操れるので、他の言語のように「不器用なデータ型である」文字列自体を弄くろう、と言う発想がそもそも存在しなかったから、である。
一方、SchemeはLisp族の中では珍しく、正規表現にマトモに取り組む実装が多い。と言うのも、ANSI Common Lispと違って、「UNIXと言うOSで育てられた期間」が異様に長かったから、と言う背景がある。
(ANSI Common Lispは、むしろ既存のOSではなく、Lisp OSないしはLispマシンOSと言われるモノを意識していた)

※5: UNIXでは長い間正規表現は使われてはいたが、一方、DOSやWindows、あるいはMacなんかの民生機のプログラマにはそれほど知られてなかった。
全てが変わったのはPerlと言うプログラミング言語が流行ったせいである。
Web黎明期から暫く、掲示板を書いたり、チャットシステムを書いたり、とPerlは今のPython以上に人々に受け入れられた。Webで何か「カッコイイ」事をするにはPerlを学ばなければならなかったのだ。
Lispがリスト処理に特化した言語だとするとPerlは文字列処理に特化した言語であり、文字列操作のためのありとあらゆる機能が詰め込まれている。
結果、Perlが人々に知らしめたのが正規表現の存在であり、これがUNIXでの長年の「習慣」と市井の人々が邂逅した初めてのケースだったんじゃないか。

※6: しかしながら末尾再帰最適化をしてくれる実装が多いのは事実である。
ただし、自然と末尾再帰最適化をしてくれる処理系から、コンパイル時に最適化オプションを指定する事によって最適化してくれる処理系までまちまち、である。
いずれにせよ、これは実装のマニュアルと相談してみた方が良い案件だとは言えよう。

※7: なお、tweak-text関数だが、実の事を言うと、現在のSchemeの仕様に於いては結構無駄が多い。言い換えると、ANSI Common Lisp向けの実装なのは間違いがない。
tweak-text関数がやってるのは、基本、文末文字(?や!や.)を判別して、次に来る文章のアタマを「大文字」にして、それ以降を「小文字にする」と言う処理なのだ。要するに「THIS IS A SENTENCE.」をフツー見かける表記「This is a sentence.」に変換する為の関数なのだ、と言って良い。
これは、ANSI Common Lispのシンボルが、基本大文字だけで構成されてる、と言う原則に則ったモノで、要するにprin1-to-stringstring-trimでリストをそのまま文字列にした以上、得られる文字列も中身は全部大文字になっている、と言う事に由来している。
言い換えると、ANSI Common Lispのシンボル、あるいは歴史的にはLispは大文字・小文字を区別せず、事実、昔のLispの教科書では

(DEFUN ADD1 (X)
 (+ X 1))

のような表記が多かったのは、それが理由である。
このように、プログラム上、(Lispには厳密にはリテラルはないが)リテラルで大文字・小文字を区別しないプログラミング言語をCASE-INSENSITIVEな言語、と呼ぶ。
これは古い言語に多く見られ、60年代末までに登場したFortran、BASIC、70年代初期に登場したPascal等に見られる性質である。これらの言語上ではJapan = JAPAN = japanであり、キャピタルケース(大文字をどう使って関数名をデザインするか)をどう駆使しようと全部同じ関数名として認識する。
一方、1970年代半ばに登場したC言語をはじめとして、Pythonや、現在主流なプログラミング言語では、リテラルは大文字と小文字を区別している。
このテのプログラミング言語をCase-Sensitiveな言語、と呼称する。当然これらの言語では、Japan≠JAPAN≠japanである。同じ「日本」を意味しても、全く別の識別子として働くだろう。
一方、Schemeは珍しく、UNIX上での滞在期間が長かった為、か、仕様は要求してなかったが実装上Case-Sensitiveで実装された処理系が多く、R6RSを境にして正式にLispとしては異例のCase-Sensitiveな言語と相成った。
従って、ANSI Common Lispでは

CL-USER> '(This is a sentence)
(THIS IS A SENTENCE)
CL-USER>

だが、Racket/Schemeだと

> '(This is a sentence)
'(This is a sentence)
>

なのである。

;; Common LispだとLispとLISPは全く同じ
CL-USER> (equal '(Lisp) '(LISP))
T
CL-USER>
;; しかしRacket/SchemeではLispとLISPは違うのである
> (equal? '(Lisp) '(LISP))
#f
>



すなわち、Schemeだと

> (define (foo)
"foo!")
> (define (FOO)
"FOO!")
>

と、fooとFOOは全く別の関数として定義出来てしまう(これはANSI Common Lispでは「出来ない」と考えて良い)。

> (foo)
"foo!"
> (FOO)
"FOO!"
>

従って、Racket/Schemeでは、Land of Lispの例に従うと、入力したメッセージをシンボルのリストとして扱う以上、ANSI Common Lispと違って、「そのままの形式として」保持されている・・・Case-Sensitiveな言語、だからだ。
つまり、Racket/Scheme上では本来、tweak-text関数のような大文字・小文字の変換関数は必要がないのである・・・・・・繰り返すが、それが必要だったのは、一般にLispは歴史的にCASE-INSENSITIVEな言語、と言う前提があったから、だったのである。
  • Xでシェアする
  • Facebookでシェアする
  • はてなブックマークに追加する
  • LINEでシェアする

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

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