HaskellとParsecでLisp REPL その5(Bool型の追加)
今回の変更点
ブール値の追加。データ型とパーサの拡張のみ。
データ型
data LispVal = Atom String | List [LispVal] | Number Integer | Bool Bool
ちゃんとshow
できるようにしておく:
showVal (Bool True) = "#t" showVal (Bool False) = "#f"
パーサ拡張
Bool
型はユーザ入力が"#t"ならBool True、"#f"ならBool Falseである。このパーサはちょっとだけややこしい。
というのは、今までの三つの種類のデータ型はそもそも先頭の文字をパースした時点でどの要素になるかがわかったのだが、Bool
型の場合Atom
型とかぶってしまっている。
まず、Atom
型のパーサのほうがBool
型よりも許容度が高いので("#t"はAtom
型としてもパース可能)、パースを試す順番はBool
のほうが先でなければいけない:
parseExpr = parseBool <|> parseAtom <|> parseList <|> parseNumber
これで"#t"がAtom
型として認識されてしまう問題は回避できる。
しかし、まだ気をつけないといけない。
今までのような実装だとたとえば"#define"のような文字列をパースする時に、parseBool
が"#"は処理し"d"を読んだ時点で諦めるので、"#"文字が次のparseAtom
に渡されない問題が発生する。
parseBool :: Parser LispVal parseBool = (try parseTrue) <|> (try parseFalse)
parseBool
がtry
を使うことによって解決する。try
はバックトラックしてくれるので、parseTrue
を試してみて失敗したら元の文字まで戻ってparseFalse
に回し、それも失敗したらまた元の文字まで戻って次のパーサに回す。
parseTrue
/parseFalse
の定義:
parseTrue :: Parser LispVal parseTrue = string "#t" >> return (Bool True) parseFalse :: Parser LispVal parseFalse = string "#f" >> return (Bool False)
ParsecのParserはモナドなので>>
とかreturn
がでてくる。"#t"や"#f"のパースが成功したら、マッチした文字列は使わず直接Bool True
/Bool False
をParserモナドに入れて返す。
評価器・REPL・Main
例によって変更なし
次回
ブール値が表現できるようになったので、ブール値を返す比較演算子を定義する。
HaskellとParsecでLisp REPL その4(四則演算)
今回の変更点
ついに四則演算の実装。入力をそのまま返すのではなく、入力を評価・変換して出力するようになり、「ちゃんとしたREPL」にちょっと近づく。
データ型とパーサ
今回は変更なし。四則演算ならAtom
、List
そしてNumber
型だけで十分。演算子がAtom
、数値がNumber
、それを式として繋げる容器がList
だ。
評価器
Evaluatorに一つ変換ルールを追加:
eval :: LispVal -> LispVal eval (List (Atom funcName : args)) = apply funcName $ map eval args eval lispVal = lispVal
ロジックとしては以下のとおり:
- ある
LispVal
がList
型で、その先頭の要素が任意のAtom
型の場合、そのList
を関数適用の式とみなす。 - まず先頭以外の要素すべてを評価する
- 評価された先頭以外の要素に対し
apply funcName
で関数適用する(funcName
は先頭要素のAtom
型が保持している文字列)
apply
がキモで、これは新しいModuleのFunctionsで定義されている。
関数適用と演算の定義
Functions内をみていく。このModuleからはapply
しか公開しない。あとは内部実装。
まずはそのapply
の定義:
apply :: String -> [LispVal] -> LispVal apply funcName args = maybe (Number 0) ($ args) $ lookup funcName primitives
すこしややこしく見えるが、やっていることはおおまかに二つ
- Haskellの標準関数である
lookup
を使ってfuncName
をprimitives
テーブルの中から探す lookup
はMaybe
型を返すので、それをmaybe
関数で処理funcName
がprimitives
に含まれず、Maybe
がNothing
だった場合(Number 0)
を返すfuncName
が見つかりMaybe
がJust 該当する関数
だった場合、その関数にargs
を適用した結果を返す
($ args) f
がf args
になるのは面白い。慣れるまですこし面食らったが・・・
primitives
テーブルは、「文字列と[LispVal] -> LispVal
型の関数のタプル」が入っているHaskellのリスト:
primitives :: [(String, [LispVal] -> LispVal)] primitives = [ ("+", numBinOp (+)), ("-", numBinOp (-)), ("*", numBinOp (*)), ("/", numBinOp div) ]
今のところ四則演算のみ。Haskell自身の四則演算関数を自前のnumBinOp
の引数にすることで[LispVal] -> LispVal
型にしている。
numBinOp
の定義:
numBinOp :: (Integer -> Integer -> Integer) -> [LispVal] -> LispVal numBinOp op args = Number $ foldl1 op $ map numerify args
Haskellの整数をとる二項演算関数を[LispVal] -> LispVal
に変換する。
LispVal
のリストであるargs
を(後述の)numerify
関数でHaskellのIntegerに変換- Integerのリストに対し、二項演算を
foldl1
する(op a b c d) = (op (op (op a b) c) d) = (((a
opb)
opc)
opd)
- その結果のIntegerを
Number
型に格納して(LispVar
化して)返す
という流れになっている。
numerify
のコード:
numerify :: LispVal -> Integer numerify (Number n) = n numerify _ = 0
パターンマッチでNumber
型からは保持する整数を取り出し、それ以外のLispVal
はDon't Careパターンで0と解釈する。
これで四則演算の関数が定義・登録された。
現在気になっている点
- numBinOpが二項演算じゃない
- 任意の数に対して
foldl1
している - パターンマッチで二項演算を強制することもできるが・・・
- 名前をnumBinOpからnumOpなどに変えたほうがいいかもしれない
- 任意の数に対して
- 動的なだけじゃなくて弱い型になっている
- 登録されていない関数適用は数値0を返す
- 数値以外の
LispVal
への四則演算適用は、そのLispVal
を数値0と解釈して続行 - 後々エラー処理を追加するのでその時に解決
REPL・Main
変更なし
実行
>> (+ 1 2) 3 // 足せる >> (+ 1 2 3) 6 // 3以上の要素に対しても適用可能 >> (* (+ 5 (- 7 3)) (/ 9 2)) 36 // 四則すべて・ネストも可 >> (+ (1 2 3)) 0 // リストは関数適用時には0として認識される・要素1でも適用可能 >> (1 2 3) (1 2 3) // 関数適用外の場合リストはそのままの値として評価される >> (+ 1 x) 1 // `Atom`も0として評価される >> (f 5) 0 // 登録されていない`Atom`を関数として使う場合の結果も0 >> quit
やはりREPLらしくなってきた。
四則演算ができるところまで実装されたリリース
src
とapp/Main.hs
にコードが記述されている。
次回
if
構文をサポートしたいので、次回はとりあえずブール値を表すデータ型の追加。
HaskellとParsecでLisp REPL その3(Number型の追加)
今回の変更点
REPLで数字を入力するとパースエラーになる。Atom
、List
に続いてNumber
もパースしデータとして扱えるようにしたい。
LispVal
にNumber
型を追加
LispVal
に新たにNumber
型を定義し、Haskellの整数を保持させる:
data LispVal = Atom String | List [LispVal] | Number Integer
showVal
関数は、単にNumber
が保持する整数の文字列を返す:
showVal (Number n) = show n
パーサを拡張
トップレベルのパーサにparseNumber
を追加:
parseExpr :: Parser LispVal parseExpr = parseAtom <|> parseList <|> parseNumber
parseNumber
の定義:
parseNumber :: Parser LispVal parseNumber = many1 digit >>= return . Number . read
数字が一つ以上続いているのを文字列として捉え、read
でHaskellのIntegerに変換してからNumber
データコンストラクタに渡し、return
でParser
モナド値にしている。
Atom
やList
と違ってロジックが簡単なのでdo
記法はなし。しかし>>=
は処理の流れがけっこう追いにくく感じる。慣れの問題?
評価器・REPL・main
前回と同じく変更なし
実行
REPLを走らせてみるとこんな感じ:
>> a a >> 1 1 >> () () >> (1 2 3) (1 2 3) >> (+ 1 2 3) (+ 1 2 3) >> quit
とりあえず数字を入力してもパースエラーは起きなくなっている。
しかしなんの計算も評価も行われず、ただ単に入力をそのまま出力しているだけ。悲しい。
次回は数値に対する四則演算を評価するように変更する。
HaskellとParsecでLisp REPL その2(Listも使えるREPL)
LISPとはそもそもLISt Processingの略なので、リストを全く扱えない状態からいち早く脱却したい。
というわけで次はリストの実装。ユーザから(a1 a2 a3)
といった文字列を受けとり、List
型として表現できるようにする。
gistからちゃんとしたgithub repoに変えてみた。リリースタグにリンクできるだけでなく、前回からの変更点も:
このようにリンクできるのがうれしい。
LispVal
型の拡張
Atom
型だけだった代数型データにList
を追加:
data LispVal = Atom String | List [LispVal]
List
はLispVal
のリストを保有する。
show
が使えるようにshowVal
も拡張:
showVal (List contents) = "(" ++ (unwords $ map show contents) ++ ")"
パーサの拡張
トップレベルのパーサparseExpr
がList
を認識するよう拡張:
parseExpr :: Parser LispVal parseExpr = parseAtom <|> parseList
まずはparseAtom
を試し、そのパースが失敗した場合parseList
を試す。
parseList :: Parser LispVal parseList = do { char '('; xs <- sepBy parseExpr (skipMany1 space); char ')'; return $ List xs; }
評価器・REPL・main
eval
やREPL、main
は変更の必要なし。現在リストは変換されず、そのまま値として扱われる。
使用例
これだけの変更で、ユーザがリストを入力するとちゃんとパースされて表示される:
~$ ./v2 >> (a b c) (a b c) >> (a (b c)) (a (b c)) >> (a (b (c (d)))) (a (b (c (d)))) >> (a b 2) No match: "lisp" (line 1, column 6): unexpected "2" expecting space, letter or "(" >> (+ a1 a2) (+ a1 a2) >> (+ 1 2) No match: "lisp" (line 1, column 4): unexpected "1" expecting space, letter or "(" >> quit ~$
再帰的な(a (b (c (d))))
といった構造もちゃんとパースできている。しかしまだ数字はパースされない。次回は数字も受け入れるようにする。
HaskellとParsecでLisp REPL その1(Atomをパースし表示するREPL)
ここ一週間ほどHaskellを試しがてら、以下のチュートリアルをいじっていた:
Write Yourself a Scheme in 48 Hours - Wikibooks, open books for an open world
まずは全部通しで実装してみて、その後コードをいろいろ書き換えたり。
このチュートリアル、非常に勉強になったしHaskellやParsec習得のために大変おすすめなのだが、ひとつ気になったのはREPLができるのが第6章という遅さ。
第2章の「パーサ作成」の時点でコードを表すデータ構造を相当作り込んでいるし、第3章では例外処理なども作っている。各パートでけっこう多機能なパーサ、評価器などを作っていってからREPLに組み合わせている感がある。
個人的には超簡単なEnd-to-EndのREPLを最初に作って、そこに機能をくっつけていくほうがしっくりくる。
というわけでその方法で作り直す段階を個別に記事にしていく。
Read-Eval-Print-Loop
最低限のLisp REPLとはなんだろうか。
そもそもREPLとはRead-Eval-Print-Loopの略なので
- ユーザから文字列を受けとり、コードを表す内部データ構造に変換 (Read)
- データ構造を評価 (Eval)
- 評価結果をプリント出力 (Print)
- はじめに戻ってユーザ入力を待つ (Loop)
を兼ね備えている必要がある。
とりあえず今回は「Lispの『関数名』『変数名』などを表すAtom
を正しくパースし、そのものとして評価し、プリントし、そしてループする」というところまで実装する。
コード:
Ultra-Minimal Lisp REPL written in Haskell - Atom only · GitHub
LispVal
データ構造
Lispコードを表現するのに代数的データ型LispVal
を定義する:
data LispVal = Atom String
Atom
型は文字列を一つ保持するデータ型。
今回はLispVal
が取り得る型はAtom
のみ。今後、機能を足していくに従ってLispVal
の型が増えていく。
LispVal
を文字列として表示できるようにShow
型クラスのインスタンスとしておく:
instance Show LispVal where show = showVal showVal :: LispVal -> String showVal (Atom name) = name
Atom
型をshow
する場合、保持している文字列が返される。
「文字列→LispVal
」パーサ
まずはトップレベルの関数:
readExpr :: String -> LispVal readExpr input = case (parse parseExpr "lisp" input) of Left err -> Atom $ "No match: " ++ show err Right val -> val
型からわかる通り、文字列からLispVal
へと変換する関数。Parsecのparse
関数と後述するparseExpr
を使っている。
parse
は第三引数の文字列に対して第一引数のパーサを適用し、Either
型を返してくる。成功したらRight
、失敗したらLeft
。
パターンマッチで、失敗したらLeft
に含まれているエラーメッセージをAtom
に入れて返し、成功ケースの場合はRight
に含まれているLispVal
を直接返す。
トップレベルのパーサ:
parseExpr :: Parser LispVal parseExpr = parseAtom
今回は非常に簡単。今後他のデータ型が増えていくに従いparseExpr
も他のパーサを組み合わせる形になっていく。
ポイントとしては、Parsecの提供するParser
モナドのなかで処理が進んでいくこと。ParsecがMonadic Parserだと言われる所以である。
parseAtom
の定義:
symbol :: Parser Char symbol = oneOf "!#$%&|*+-/:<=>?@^_~" parseAtom :: Parser LispVal parseAtom = do { first <- (letter <|> symbol); rest <- many (letter <|> digit <|> symbol); return $ Atom (first:rest); }
LispのAtom
の特徴は「アルファベット文字か指定の特殊記号で始まり、それら文字か数字が0個以上続く」というもの。
それをほぼそのまま書き表している。Parser
モナドのなかで処理しているのでdo
記法。最後にParser LispVal
型のものを返している。
評価器
評価法は「LispVal -> LispVal」の変換則を再帰的に加えていくというもの。適用できる変換則がないLispVal
の値はそのLispVal
そのもの。
eval :: LispVal -> LispVal eval lispVal = lispVal
今回はLispVal
がAtom
だけで、変換則はない。非常に単純。
REPL各部分とmain関数
まずは標準入力から文字列を受けとる関数:
readPrompt :: String -> IO String readPrompt prompt = putStr prompt >> hFlush stdout >> getLine
上で定義した関数を組み合わせて文字列をLisp的に評価し結果をプリントする関数:
readEvalPrint :: String -> IO() readEvalPrint = putStrLn . show . eval . readExpr
readPrompt
とreadExpr
が分離されているのが「REPL」という名前からすると微妙・・・ そもそもパーサの部分はReadなのかEvalなのか?
特定の入力をうけとるまでループし続ける関数:
loopUntil :: (a -> Bool) -> IO a -> (a -> IO ()) -> IO () loopUntil pred prompt action = do { x <- prompt; if pred x then return () else (action x >> loopUntil pred prompt action) }
もし停止条件が入らない場合は:
loop prompt action = prompt >>= action >> loop prompt action
というような書き方になるだろうか。停止条件のための条件分岐が必要なので少し長めのコードになっている。
do {}
記法にマルチラインの式が入る場合の書き方がイマイチ自信がない・・・
これらの関数を組み合わせたmain
:
main :: IO () main = loopUntil (== "quit") (readPrompt ">> ") readEvalPrint
"quit"が入力されるまでループし続けて、入力された文字列を評価してプリントしていく。
使用例
ghc -package parsec -o lisp-v1 --make lisp-v1.hs
などでコンパイルできる。
./lisp-v1
を実行してみると:
~$ ./lisp-v1 >> a a >> a1 a1 >> +a1 +a1 >> 1 No match: "lisp" (line 1, column 1): unexpected "1" expecting letter >> (+ a b) No match: "lisp" (line 1, column 1): unexpected "(" expecting letter >> quit ~$
このようなREPLが走る。まだ数字やリストは受け入れられない。
次回はリストを受けとりパースできるようにする。
『アホでマヌケなプログラミング』読んだ
lepton著『アホでマヌケなプログラミング』を読んだ。
非プログラマ(ゆるくプログラマ志望な人?)に「プログラマの実態」を紹介する本。
著者は企業の新卒向けプログラミング研修の講師などをよく務めていたらしい。「文系卒、プログラミング経験なし」の新卒人材をプログラマにしたててプロジェクトに投入する現場のこぼれ話がたくさん載っている。研修を受ける人々の話だったり、使われているプログラミング言語の話だったり、実際に行われているプロジェクトの内実・人間模様だったり。
出版されたのが2003年ということもあってやはり少し古い感じがするのと、そもそも「プログラミング経験なしの人材の大量投入」という個人的にはあまり関わったことのない現場についての話なのとで、あまり自分の実体験と合致する印象ではなかった。ただその分なかなか興味深かったことも確か。
2000年問題や銀行合併、はてはΣ計画などの過去のすったもんだあったプロジェクトについての話もゴシップ的な楽しさがある。営業と開発の確執やら、担当者不在で繁忙期にO(n**2)のアルゴリズムでバッチ処理しなきゃいけなかった時の話やら、「本当にあったプログラマの怖い話」あるいは「日本式プログラマという種族の文化人類学的資料」といった感じ。文体もまさに2000年代初頭のインターネットによくあった、微妙にくだけた理系おじさん調。
癖はすごくあるけど、「こういうプログラマ界隈が主流だった」ということを少しだけ肌で感じられるだけでもとても面白い本だった。
しかし、これを読んでなお「プログラマになろうかな」と思い続けた読者が果たしていたのだろうか?