謎の⾔語Forthが謎なので実装した
2018-10-25, lisp meetup #69
今⽇のお話
⾔語を実装したいが、
それを実⾏する仮想機械の設計がわからず、
スタック機械みたいな⾔語Forthがあると知ったが、
Forth謎すぎてファンタジーなので、
とりあえずForth処理系つくってみた。
動機: ⾔語を実装したい1/3
先⽣、⾔語を実装したいです……
理由
既存の⾔語への理解を深めたい
Common Lispもっと理解したい
低レイヤー覗きたい
使いたい
かっこいい
動機: ⾔語を実装したい2/3
⾔語実装の軌跡1/2
nutslisp
Lisp-2、レキシカルスコープ、パッケージ、Nim製
「CLっぽいもの」という壮⼤な⽬標の前に撃沈
https://github.com/t-sin/nutslisp
lisc
ほぼ純Lisp、Pythonのリスト内包表記ワンライナー
アホス
https://github.com/t-sin/lisc
svm
⾔語実装VMという名のCPUエミュレータもどき
メモリ操作つらい
https://github.com/t-sin/svm
動機: ⾔語を実装したい3/3
⾔語実装の軌跡2/2
secdm
関数型⾔語のためのVM SECDマシンの実装
フィボナッチ数計算成功、S式パーサ作成で挫折
https://github.com/t-sin/secdm
wl (設計中)
C⾔語製、VM型でクロージャのあるLisp-2(予定)
VMの設計がまったくわからんぬ
https://github.com/t-sin/wl
動機: 仮想機械の設計?1/3
仮想機械の設計、
どうやったらいいか
まったくわからん
😭😭😭😭😭😭😭
動機: 仮想機械の設計?2/3
仮想機械(かそうきかい、仮想マシン、バーチャルマシン、
英語: virtual machine、VM)とは、コンピュータの動作をエ
ミュレートするソフトウェアやフレームワークである。ま
た、エミュレートされた仮想のコンピュータそのものも仮想
機械という。
─ 『仮想機械』, Wikipediaより
動機: 仮想機械の設計?3/3
仮想機械の実装を⾒て参考にしよう!
SECDマシン
→論⽂読んでつくった。低レベルすぎてつらい…
Lua
→⼩さいけど、最適化ゆえかけっこう複雑…
Python
→でかい…でかすぎる…
Forthという⾔語があるらしい…?
その⾔語はまるで
アセンブラみたいに低レベル
表現⼒・抽象度が⾼い
しかもスタックマシンみたいな⾔語
らしい
これやれば仮想機械設計の感触掴めるかもしれない
謎の⾔語、Forth 1/5
Wikipediaの記事より特徴を要約すると…
スタックベース
逆ポーランド記法(後置記法)
⼿続き型
対話型環境
コンパイラモード
謎の⾔語、Forth 2/5
典型的なForth の実装には、LISP におけるRead–eval–print
loop(英語版)(REPL)に対応する、⼊⼒されたワードを即
座に実⾏する対話型のインタプリタモードと(これは、正規
のオペレーティングシステムがないシステム向けのシェルに
も適している)、後の実⾏のために⼀連のワードをコンパイ
ルするモードのふたつのモードがある。後者にはコロン(:)
というワードにより遷移しセミコロン(;)というワードで脱
する。
─ 『Forth』, Wikipediaより
謎の⾔語、Forth 3/5
Forth は現在Open Firmware のようなブートローダや宇宙開
発[1]、組込みシステム、ロボット制御などに使われている。
─ 『Forth』, Wikipediaより
謎の⾔語、Forth 4/5
なぞすぎるプロダクト群…
1991 - http://www.1-9-9-1.com/
Forthで書かれたHTTPサーバー
なぜかコードがすごくちいさい
forsh - https://bitbucket.org/cowile/forsh
Forthで書かれたシェル
もちろん後置記法
fmacs - https://github.com/larsbrinkhoff/fmacs
Forthで書かれたEmacs
どういうこと??
謎の⾔語、Forth 5/5
なんなのこの⾔語。
Common Lisp感があるけど、謎。
Forthのことぜんぜんわからない…。
Forthが謎なので…
実装してみた
uf:Forthインタプリタ
https://github.com/t-sin/uf をつくった
特徴
Common Lisp製
ワード(Forthにおける関数)の呼び出し∕定義
スタック操作ワード
数値計算・数値⽐較のワード
条件分岐
コンパイラなし
対話環境がなんかおかしい
Forthのいろは
1. プログラムはスペース区切り
2. トークンがディクショナリに
あったらワード(関数)の本体コードにジャンプして実
⾏
なかったら定数リテラルなのでスタックに積む
これだけ!
プログラム例1
1 + 2 * 10 を計算
// スタック: []
1
// スタック: [1]
2
// スタック: [2 1]
10
// スタック: [10 2 1]
*
// スタック: [20 1]
+
// スタック: [21]
プログラム例2
数値を⾜した結果をASCIIコードと⾒て出⼒
// スタック: []
40 2 +
// スタック: [42]
emit
// => *
// スタック: []
プログラム例3 1/7
フィボナッチ数列を計算、する前に…
プログラム例3 2/7
前提: ufの持つ命令(⼀部)
スタック操作ワード
swap (2要素交換), dup (複製), rot (先頭要素と3番⽬を
交換), drop (削除), over (2番⽬の要素を先頭に複製)
I/Oワード
. (スタックトップを出⼒), .s (スタックをデバッグ出⼒)
数値演算・⽐較ワード
+ , - , * , / , = , <
論理演算ワード
and , or , not
プログラム例3 3/7
フィボナッチ数列を計算
まずはスタック要素を2つ読んで⽐較する述語 <= を定義する
: <= over over < rot swap = or ;
プログラム例3 4/7
<= 使⽤例
(<= 20 21) → t を計算
21 20
// スタック: [20 21]
<=
// 上の定義の⼀語め (over)にジャンプ
// 引数を複製する
over
// スタック: [21 20 21]
over
// スタック: [20 21 20 21]
// Forthは偽が0、真が0以外
<
// スタック: [-1 20 21]
(つづく)
プログラム例3 5/7
<= 使⽤例(つづき)
// スタック: [-1 20 21]
rot
// スタック: [21 20 -1]
swap
// スタック: [20 21 -1]
=
// スタック: [0 -1]
or
// スタック: [-1]
// ワードの終わりが来たので<=呼び出し位置にジャンプ
;
プログラム例3 6/7
つぎに、フィボナッチ数を再帰的に計算するワードを定義する
: fib
dup 0 swap <= if
drop 0
else
dup 1 = if
drop 1
else
dup 1 swap - fib swap 2 swap - fib +
then
then
;
引数としてフィボナッチ数の項数をひとつ受けとる
if で⼆回引数を使うので、最初に引数を複製している
Forthの再帰はLispのダイナミックスコープにおける名前参照
みたいにふるまう
プログラム例3 7/7
さいごに、引数をスタックに積んで fib を呼び出す
// スタック: []
10
// スタック: [10]
fib
// スタック: [55]
.
// せっかくなので出⼒してみる
55 ok
// スタック: []
実装
ここからは実装のしかたのお話
実装: パーサ
スペース以外の⽂字で区切って切り出し、リストに溜める
ついでに数値はCLの数値に変換しておく
(defun parse (stream)
(let (code buf atomp numberp)
(flet ((read-atom (ch)
...))
(loop
:for ch := (read-char stream nil :eof)
:until (eq ch :eof)
:do (case ch
(#space (terminate-atom))
(#newline (terminate-atom))
(t (read-atom ch)))
:finally (progn
(terminate-atom)
(return (nreverse code)))))))
split-sequence を使えばほぼ⼀撃ですねこれ(今きづいた
実装: ワードまわり
ワードの構造体と実⾏状態の構造体をつくる
(defstruct word name fn start system-p)
(defstruct vm code ip dict stack
rstack ifdepth skip-to debug-p)
ディクショナリは word のリスト
find とか push で探索や追加を⾏う
通常はスタック2本(データ・呼び出し)でよいが、
インタプリタとするにはifのネストを読み⾶ばすために3本⽬
が要る
実装: 解釈器1/2
パーサが⽣成したAST(CLのアトムのリスト)を解釈して実⾏す
る
if のスキップ時と通常の実⾏時の2つの状態がある
通常の解釈
: だったら→ワード定義処理(後述)
; だったら→
if だったら→条件評価後、true部を実⾏or true部をスキップ
(ネスト数を覚えておく)
else だったら→true部実⾏してたので、else部をスキップ
(ネスト数を覚えておく)
それ以外→ワード呼び出しなので、コールスタックに現在の
PC積む、ワード本体にPCをセット
実装: 解釈器2/2
if で実⾏しない部分( if がネストし得る)を読み⾶ばす
if ⽂スキップ
if だったら→ネスト数を1加算
then だったら→ネスト数を1減算
もしスキップ開始時のネスト数と今のネスト数が同じな
ら
→スキップ処理終了
それ以外→読み⾶ばす
実装: ワード定義1/2
ufでは : によるワードの定義も解釈時に⾏う
基本的には、
1. : がきたら、
2. 名前を読み取り、
3. プログラム開始位置を覚え、
4. ; まで読み⾶ばし、
5. 名前と開始位置から word 構造体をディクショナリに push
という流れ
: ... ; のネストは不可
これはForth 2012の仕様でも同様
「コンパイルモード時にコンパイルモードに⼊る」の排除か
実装: ワード定義2/2
だいたいこんなコードです(ハミ出る)
(defun define-word (vm)
(let ((name (get-atom vm)))
(when (null name)
(error "invalid word definition : it doesn't have a name.
(let ((start-pos (vm-ip vm)))
(loop
:for atom := (get-atom vm)
:until (eq atom 'uf/dict::|;|)
:do (when (null atom)
(error "invalid word definition '~a': it doesn't
(let ((word (make-word :name name :system-p nil :start
(let ((w (find name (vm-dict vm) :key #'word-name)))
(if (and (not (null w)) (word-system-p w))
(error "cannot overwrite the predefined word: ~s"
(push word (vm-dict vm))))))))
実装: 初期ワード定義
. (出⼒)とか + とかを実装していく
ここはひたすら気合いです
;; I/O
(defword (|.|)
(format t "~a" (pop (vm-stack vm))))
(defword (|cr|)
(terpri))
(defword (|emit|)
(format t "~a" (code-char (pop (vm-stack vm)))))
(defword (|.s|)
(format t "~s" (vm-stack vm)))
;; stack maneuvers
(defword (|swap|)
(let ((o1 (pop (vm-stack vm)))
(o2 (pop (vm-stack vm))))
(push o1 (vm-stack vm))
(push o2 (vm-stack vm))))
たったこれだけです
ここからまとめに⼊ります
ufの実装で反省したこと
1. 処理系のモードがないことによる苦労
2. VMの状態を引き回せない問題
反省点1: 処理系のモードがないことによ
る苦労
if の解釈のためにめんどくさいことをした
if スキップのフェーズはインタプリタに固有の処理
分岐をコンパイル時でジャンプに変換できれば不要
反省点2: VM状態を引き回せない問題1/2
ufのroswellコマンドによるシェルが変…
~/code/uf$ roswell/ufi.ros
uf, Ursa Major Forth, 0.1.0
Ctrl-D to exit
: fn hello . ; fn
hellook
fn
ok
定義したワードがREPLの⾏を跨ぐと消えている
反省点2: VM状態を引き回せない問題2/2
これは実は、REPLにおいてVMのコードを上書きしており、
word に格納された本体開始位置が無効になっている
(loop
:for line := (read-line stream nil :eof)
:until (eq line :eof)
:for code := (with-input-from-string (in line) (parse in))
:do (handler-bind
((condition (lambda (c) (format t "not ok~% ~s~%"
;; ここでコードを上書きしてしまっている!
(setf (vm-code vm) code
(vm-ip vm) 0)
(execute vm))
:do (format t "ok~%"))))
解決策は、
コンパイルモードを実装する(ほしい)
code を append する(ハック)
ufのこれから
⽂字列は配列といった、より実⽤的なデータ構造を実装した
い
Forthの⽂字列例: " hello world" .
パーサでの特別扱いが必要
実⾏モード(実⾏・解釈・コンパイル)を実装したい
if の問題やREPLの微妙さを⼀気に解消できそう
ついでにバイトコードにしてみたい
なにかに埋め込んで使ってみたい
お絵描き?
弾幕STGのDSL?
まとめ
簡単なForthインタプリタをCommon Lispで実装した
謎は深まるばかり…
インタプリタでも、純Lispと違い低レイヤーを意識させられた
スタック型仮想機械の設計、すこし⾒えてきたような…
コンパイラに向けて⾛っていきたい
なにかに組み込んで使いたい

謎の言語Forthが謎なので実装した